diff --git a/.clang-format b/.clang-format index 1c7154a4a..02c9e3c7b 100644 --- a/.clang-format +++ b/.clang-format @@ -5,7 +5,6 @@ UseTab: Never ColumnLimit: 160 PointerAlignment: Left AllowShortEnumsOnASingleLine: false -AlignConsecutiveAssignments: - Enabled: true - AcrossEmptyLines: true - AcrossComments: true + +AlignTrailingComments: true +ReflowComments: false # Prevents automatic reflowing of comment text diff --git a/help/audio_mixer.html b/help/mixer.html similarity index 100% rename from help/audio_mixer.html rename to help/mixer.html diff --git a/icons/effects_loop.png b/icons/effects_loop.png index 776755769..d48689adf 100644 Binary files a/icons/effects_loop.png and b/icons/effects_loop.png differ diff --git a/test/test_navigation.py b/test/test_navigation.py index 235dc8662..fe44cadbf 100755 --- a/test/test_navigation.py +++ b/test/test_navigation.py @@ -83,7 +83,7 @@ def test(title, cmd, params, response, timeout=1): 'Enable test mode', '/cuia/test_mode', 1, 'TEST_MODE: \[1\]\r\n') # Show mixer then clean so we know where we are starting from -cuia('/cuia/show_view', 'audio_mixer') +cuia('/cuia/show_view', 'mixer') overall_result &= test('Clean all', '/cuia/clean_all', 'CONFIRM', 'TEST_MODE: zyngui.zynthian_gui_main\r\n', 10) @@ -119,17 +119,17 @@ def test(title, cmd, params, response, timeout=1): None, 'TEST_MODE: zyngui.zynthian_gui_control\r\n') # Test F2 -cuia('/cuia/show_view', 'audio_mixer') +cuia('/cuia/show_view', 'mixer') overall_result &= test('Mixer: bold layer', '/cuia/switch_layer_bold', None, 'TEST_MODE: zyngui.zynthian_gui_main\r\n') # Test H2 -cuia('/cuia/show_view', 'audio_mixer') +cuia('/cuia/show_view', 'mixer') overall_result &= test('Mixer: bold snap/learn', '/cuia/switch_snapshot_bold', None, 'TEST_MODE: zyngui.zynthian_gui_snapshot\r\n') # Test I2 -cuia('/cuia/show_view', 'audio_mixer') +cuia('/cuia/show_view', 'mixer') overall_result &= test('Mixer: bold select', '/cuia/switch_select_bold', None, 'TEST_MODE: zyngui.zynthian_gui_layer_options\r\n') diff --git a/zynautoconnect/zynthian_autoconnect.py b/zynautoconnect/zynthian_autoconnect.py index 0d6c20288..eb896c4f0 100755 --- a/zynautoconnect/zynthian_autoconnect.py +++ b/zynautoconnect/zynthian_autoconnect.py @@ -31,6 +31,7 @@ import pexpect import logging import alsaaudio +import traceback from time import sleep from threading import Thread, Lock @@ -63,17 +64,19 @@ def __init__(self, name): self.aliases = [name, name] def set_alias(self, alias): - pass + if len(self.aliases < 2): + self.aliases.append(alias) def unset_alias(self, alias): - pass + try: + self.aliases.remove(alias) + except: + pass # ------------------------------------------------------------------------------- # Define some Constants and Global Variables # ------------------------------------------------------------------------------- -MAIN_MIX_CHAN = 17 # TODO: Get this from mixer - jclient = None # JACK client thread = None # Thread to check for changed MIDI ports lock = None # Manage concurrence @@ -116,6 +119,16 @@ def unset_alias(self, alias): # Map of user friendly names indexed by device uid (alias[0]) midi_port_names = {} +# External MIDI clock device to sync +ext_clock_zmip = -1 +ext_clock_device_name = None + +# List of MIDI output ports to which to send MIDI clock +midi_clock_output_ports = [] + +# List of MIDI zmips not feeding zynseq +zynseq_input_exclude_ports = [] + # Get the main jack audio device jack_audio_device = "" for proc in psutil.process_iter(['pid', 'name', 'cmdline']): @@ -150,7 +163,7 @@ def set_port_friendly_name(port, friendly_name=None): """Set the friendly name for a JACK port port : JACK port object - friendly_name : New friendly name (optional) Default:Reset to ALSA name + friendly_name : New friendly name (optional) Default:Reset to ALSA name """ global midi_port_names @@ -284,20 +297,21 @@ def get_midi_in_devid_by_uid(uid, mapped=False): mapped : True to use physical port mapping """ - for i, port in enumerate(devices_in): - try: - if mapped: - if port.aliases[0] == uid: - return i - else: - uid_parts = uid.split('/', 1) - if len(uid_parts) > 1: - if uid_parts[1] == port.aliases[0].split('/', 1)[1]: + for devices in (devices_in, devices_out): + for i, port in enumerate(devices): + try: + if mapped: + if port.aliases[0] == uid: return i - elif port.aliases[0] == uid: - return i - except: - pass + else: + uid_parts = uid.split('/', 1) + if len(uid_parts) > 1: + if uid_parts[1] == port.aliases[0].split('/', 1)[1]: + return i + elif port.aliases[0] == uid: + return i + except: + pass return None @@ -402,10 +416,8 @@ def get_sidechain_portnames(jackname=None): pass return result - # ------------------------------------------------------------------------------ - def request_audio_connect(fast=False): """Request audio connection graph refresh @@ -498,6 +510,96 @@ def remove_hw_port(port): return True return False +# MIDI clock input management + +def set_ext_clock_zmip(idev): + global ext_clock_zmip, ext_clock_device_name + ext_clock_zmip = idev + try: + ext_clock_device_name = devices_in[idev].aliases[0] + except: + pass + request_midi_connect() + +def get_ext_clock_zmip(): + return ext_clock_zmip + +def set_ext_clock_device_name(devname): + global ext_clock_zmip, ext_clock_device_name + ext_clock_zmip = -1 + ext_clock_device_name = devname + request_midi_connect() + +def get_ext_clock_device_name(): + return ext_clock_device_name + +# MIDI clock outputs management + +def set_midi_clock_output_zmop(izmop, send): + global midi_clock_output_ports + port_name = devices_out[izmop].aliases[0] + if send: + if port_name not in midi_clock_output_ports: + midi_clock_output_ports.append(port_name) + request_midi_connect() + else: + if port_name in midi_clock_output_ports: + midi_clock_output_ports.remove(port_name) + request_midi_connect() + +def toggle_midi_clock_output_zmop(izmop): + global midi_clock_output_ports + port_name = devices_out[izmop].aliases[0] + if port_name in midi_clock_output_ports: + midi_clock_output_ports.remove(port_name) + else: + midi_clock_output_ports.append(port_name) + request_midi_connect() + +def set_midi_clock_output_ports(port_names): + global midi_clock_output_ports + if not port_names: + midi_clock_output_ports = [] + else: + midi_clock_output_ports = port_names + request_midi_connect() + +def get_midi_clock_output_ports(): + return midi_clock_output_ports + +# Zynseq inputs management + +def set_zynseq_input_zmop(izmop, enable): + global zynseq_input_exclude_ports + port_name = devices_in[izmop].aliases[0] + if enable and port_name in zynseq_input_exclude_ports: + zynseq_input_exclude_ports.remove(port_name) + request_midi_connect() + elif not enable and port_name not in zynseq_input_exclude_ports: + zynseq_input_exclude_ports.append(port_name) + request_midi_connect() + +def toggle_zynseq_input_zmop(izmop): + global zynseq_input_exclude_ports + port_name = devices_in[izmop].aliases[0] + if port_name in zynseq_input_exclude_ports: + zynseq_input_exclude_ports.remove(port_name) + else: + zynseq_input_exclude_ports.append(port_name) + request_midi_connect() + +def set_zynseq_exclude_ports(port_names): + global zynseq_input_exclude_ports + if not port_names: + zynseq_input_exclude_ports = [] + else: + zynseq_input_exclude_ports = port_names + request_midi_connect() + +def get_zynseq_exclude_ports(): + return zynseq_input_exclude_ports + +# MIDI routing def update_hw_midi_ports(force=False): """Update lists of external (hardware) source and destination MIDI ports @@ -572,7 +674,7 @@ def midi_autoconnect(): deferred_midi_connect = False # logger.info("ZynAutoConnect: MIDI ...") - global zyn_routed_midi + global zyn_routed_midi, ext_clock_zmip new_idev = [] # List of newly detected input ports @@ -601,6 +703,11 @@ def midi_autoconnect(): break if devnum is not None: busy_idevs.append(devnum) + + # Enable external MIDI-clock sync for the configured device + if devices_in[devnum].aliases[0] == ext_clock_device_name: + ext_clock_zmip = devnum + # Try to connect ctrldev driver's RT MIDI processor between input device and zmip try: driver = state_manager.ctrldev_manager.drivers[devnum] @@ -618,12 +725,15 @@ def midi_autoconnect(): # else => Connect input device to zmip directly required_routes[f"ZynMidiRouter:dev{devnum}_in"].add(hwsp.name) - for i in range(0, max_num_devs): # Delete disconnected input devices from list and unload driver if i not in busy_idevs and devices_in[i] is not None: logger.debug(f"Disconnected MIDI-in device {i}: {devices_in[i].name}") devices_in[i] = None + # Disable external MIDI clock sync when device is disconnected + if ext_clock_zmip == i: + ext_clock_zmip = -1 + # Unload device drivers state_manager.ctrldev_manager.unload_driver(i) # Connect MIDI Output Devices @@ -742,6 +852,47 @@ def midi_autoconnect(): # Connect zynseq output to ZynMidiRouter:step_in required_routes["ZynMidiRouter:step_in"].add("zynseq:output") + # Connect ZynMidiRouter:step_out to zynseq input + required_routes["zynseq:input"].add("ZynMidiRouter:step_out") + # Route/unroute the devices from zmop_step as configured in zynseq_input_exclude_ports + for idev, port in enumerate(devices_in): + if idev >= max_num_devs: + break + if port: + if port.aliases[0] in zynseq_input_exclude_ports: + lib_zyncore.zmop_set_route_from(state_manager.get_zmop_step_index(), idev, 0) + else: + lib_zyncore.zmop_set_route_from(state_manager.get_zmop_step_index(), idev, 1) + + # This doesn't work well! + # Reverted to old behavior: ZynMidiRouter:step_out => zynseq:input + # Connect zynseq to selected input devices + """ + for idev, port in enumerate(devices_in): + if idev >= max_num_devs: + break + if port and port.aliases[0] not in zynseq_input_exclude_ports: + try: + required_routes["zynseq:input"].add(port.name) + except: + logger.warning(f"Unable to connect '{port}' to Zynseq") + """ + + # Connect MIDI clock output to selected MIDI output devices + if midi_clock_output_ports: + for idev, port in enumerate(devices_out): + if port and port.aliases[0] in midi_clock_output_ports: + try: + required_routes[port.name].add("zynseq:clock") + except: + logger.warning(f"Unable to connect MIDI clock to '{port}'") + + # Connect zynseq clock input + if 0 <= ext_clock_zmip <= len(devices_in) and devices_in[ext_clock_zmip]: + required_routes["zynseq:clock_in"] = {devices_in[ext_clock_zmip].name} + else: + ext_clock_zmip = -1 + # Add SMF player to MIDI input devices idev = state_manager.get_zmip_seq_index() if devices_in[idev] is None: @@ -770,9 +921,9 @@ def midi_autoconnect(): ports = jclient.get_ports(proc.get_jackname(True), is_midi=True, is_output=True) required_routes["ZynMidiRouter:ctrl_in"].add(ports[0].name) ctrl_fb_procs.append(proc) - # logging.debug(f"Routed controller feedback from {proc.get_jackname(True)}") + # logger.debug(f"Routed controller feedback from {proc.get_jackname(True)}") except Exception as e: - # logging.error(f"Can't route controller feedback from {proc.get_name()} => {e}") + # logger.error(f"Can't route controller feedback from {proc.get_name()} => {e}") pass # Remove from control feedback list those processors removed from chains @@ -781,7 +932,7 @@ def midi_autoconnect(): del ctrl_fb_procs[i] # Connect ZynMidiRouter:step_out to ZynthStep input - required_routes["zynseq:input"].add("ZynMidiRouter:step_out") + #required_routes["zynseq:input"].add("ZynMidiRouter:step_out") # Connect ZynMidiRouter:ctrl_out to enabled MIDI-FB ports (MIDI-Controller FeedBack) # TODO => We need a new mechanism for this!! Or simply use the ctrldev drivers @@ -811,7 +962,7 @@ def midi_autoconnect(): current_routes = jclient.get_all_connections(dst) except Exception as e: current_routes = [] - logging.warning(e) + logger.warning(e) for src in current_routes: if src.name in sources: sources.remove(src.name) @@ -829,6 +980,13 @@ def midi_autoconnect(): except: pass + # Connect clippy + for port in jclient.get_ports("clippy", is_midi=True, is_input=True): + try: + jclient.connect("zynseq:clippy", port) + except: + pass # Don't care about already connected ports + # Autoload new drivers for i in new_idev: state_manager.ctrldev_manager.load_driver(i) @@ -862,30 +1020,27 @@ def audio_autoconnect(): # Chain audio routing for chain_id in chain_manager.chains: + chain = chain_manager.get_chain(chain_id) + if not chain.is_audio(): + continue routes = chain_manager.get_chain_audio_routing(chain_id) - normalise = 0 in chain_manager.chains[chain_id].audio_out and chain_manager.chains[0].fader_pos == 0 and len( - chain_manager.chains[chain_id].audio_slots) == chain_manager.chains[chain_id].fader_pos - state_manager.zynmixer.normalise(chain_manager.chains[chain_id].mixer_chan, normalise) for dst in list(routes): if isinstance(dst, int): # Destination is a chain route = routes.pop(dst) dst_chain = chain_manager.get_chain(dst) if dst_chain: - if dst_chain.audio_slots and dst_chain.fader_pos: - for proc in dst_chain.audio_slots[0]: - routes[proc.get_jackname()] = route - elif dst_chain.is_synth(): + if dst_chain.is_synth(): proc = dst_chain.synth_slots[0][0] if proc.type == "Special": routes[proc.get_jackname()] = route else: - if dst == 0: - for name in list(route): - if name.startswith('zynmixer:output'): - # Use mixer internal normalisation - route.remove(name) - routes[f"zynmixer:input_{dst_chain.mixer_chan + 1:02d}"] = route + for proc in chain_manager.chains[dst].audio_slots[0]: + #TODO: Handle empty chain + jackname = proc.get_jackname() + if jackname.startswith("zynmixer"): + jackname += f":input_{proc.mixer_chan:02d}" + routes[jackname] = route for dst in routes: if dst in sidechain_ports: # This is an exact match so we do want to route exactly this @@ -909,16 +1064,22 @@ def audio_autoconnect(): dst = dst_ports[min(i, dst_count - 1)] required_routes[dst.name].add(src.name) - # Connect metronome to aux - required_routes[f"zynmixer:input_{MAIN_MIX_CHAN}a"].add("zynseq:metronome") - required_routes[f"zynmixer:input_{MAIN_MIX_CHAN}b"].add("zynseq:metronome") - - # Connect global audio player to aux - if state_manager.audio_player and state_manager.audio_player.jackname: - ports = jclient.get_ports( - state_manager.audio_player.jackname, is_output=True, is_audio=True) - required_routes[f"zynmixer:input_{MAIN_MIX_CHAN}a"].add(ports[0].name) - required_routes[f"zynmixer:input_{MAIN_MIX_CHAN}b"].add(ports[1].name) + try: + # Connect metronome to aux + required_routes["zynmixer_bus:input_01a"].add("zynseq:metronome") + required_routes["zynmixer_bus:input_01b"].add("zynseq:metronome") + + # Connect solo trunk + required_routes[f"zynmixer_bus:solo_a"].add("zynmixer_chan:solo_a") + required_routes[f"zynmixer_bus:solo_b"].add("zynmixer_chan:solo_b") + + # Connect global audio player to aux + if state_manager.audio_player and state_manager.audio_player.jackname: + ports = jclient.get_ports(state_manager.audio_player.jackname, is_output=True, is_audio=True) + required_routes["zynmixer_bus:input_01a"].add(ports[0].name) + required_routes["zynmixer_bus:input_01b"].add(ports[1].name) + except Exception as e: + logger.warning(e) # Connect inputs to aubionotes if zynthian_gui_config.midi_aubionotes_enabled: @@ -936,12 +1097,23 @@ def audio_autoconnect(): required_routes.pop(dst) # Replicate main output to headphones - hp_ports = jclient.get_ports( - "Headphones:playback", is_input=True, is_audio=True) + hp_ports = jclient.get_ports("Headphones:playback", is_input=True, is_audio=True) if len(hp_ports) >= 2: required_routes[hp_ports[0].name] = required_routes[hw_audio_dst_ports[0].name] required_routes[hp_ports[1].name] = required_routes[hw_audio_dst_ports[1].name] + # Enable zynmixer internal normalised routes and remove corresponding jack graph connections + if "zynmixer_bus:input_00a" in required_routes and "zynmixer_bus:input_00b" in required_routes: + for chan in range(2, state_manager.zynmixer_bus.MAX_NUM_CHANNELS): + bus_route_a = f"zynmixer_bus:output_{chan:02d}a" + bus_route_b = f"zynmixer_bus:output_{chan:02d}b" + if bus_route_a in required_routes["zynmixer_bus:input_00a"] and bus_route_b in required_routes["zynmixer_bus:input_00b"]: + required_routes["zynmixer_bus:input_00a"].remove(bus_route_a) + required_routes["zynmixer_bus:input_00b"].remove(bus_route_b) + state_manager.zynmixer_bus.normalise(chan, 1) + else: + state_manager.zynmixer_bus.normalise(chan, 0) + # Connect and disconnect routes for dst, sources in required_routes.items(): if dst not in zyn_routed_audio: @@ -952,7 +1124,7 @@ def audio_autoconnect(): current_routes = jclient.get_all_connections(dst) except Exception as e: current_routes = [] - logging.warning(e) + logger.warning(e) for src in current_routes: if src.name in sources: continue @@ -1032,7 +1204,7 @@ def update_hw_audio_ports(): for chain in chain_manager.chains.values(): chain.rebuild_audio_graph() except Exception as e: - logging.error(e) + logger.error(e) return dirty @@ -1103,7 +1275,7 @@ def start_alsa_in(device): port.set_alias(f"{device} {i + 1}") return True sleep(0.1) - logging.warning(f"Failed to set {device} aliases") + logger.warning(f"Failed to set {device} aliases") return True @@ -1131,7 +1303,7 @@ def start_alsa_out(device): port.set_alias(f"{device} {i + 1}") return True sleep(0.1) - logging.warning(f"Failed to set {device} aliases") + logger.warning(f"Failed to set {device} aliases") return True @@ -1158,8 +1330,8 @@ def audio_connect_ffmpeg(timeout=2.0): try: # TODO: Do we want post fader, post effects feed? # => It's just for recording video tutorials, but if the recorded video is about post-fader effects ... - jclient.connect(f"zynmixer:output_{MAIN_MIX_CHAN}a", "ffmpeg:input_1") - jclient.connect(f"zynmixer:output_{MAIN_MIX_CHAN}b", "ffmpeg:input_2") + jclient.connect("zynmixer_bus:output_00a", "ffmpeg:input_1") + jclient.connect("zynmixer_bus:output_00b", "ffmpeg:input_2") return except: sleep(0.1) @@ -1192,11 +1364,13 @@ def build_midi_port_name(port): elif port.name.startswith("ZynMaster"): return port.name, "CV/Gate" elif port.name.startswith("zynseq"): - return port.name, "Step-Sequencer" + return port.name, "Step Sequencer" elif port.name.startswith("zynsmf"): return port.name, "MIDI player" elif port.name.startswith("ZynMidiRouter:seq_in"): return port.name, "Router Feedback" + elif port.name.startswith("jackmidiola"): + return port.name, "DMX" elif port.name.startswith("jacknetumpd:netump_"): ep_name = jack.get_property(port.uuid, "UMPEndpointName") if ep_name: @@ -1299,7 +1473,7 @@ def update_midi_port_aliases(port): else: port.set_alias(alias1) except: - logging.warning(f"Unable to set alias for port {port.name}") + logger.warning(f"Unable to set alias for port {port.name}") return False return True @@ -1356,7 +1530,8 @@ def auto_connect_thread(): do_audio = False except Exception as err: - logger.error("ZynAutoConnect ERROR: {}".format(err)) + #logger.error(err) + logging.exception(traceback.format_exc()) sleep(deferred_inc) deferred_count += deferred_inc @@ -1388,7 +1563,7 @@ def release_lock(): try: lock.release() except: - logging.warning("Attempted to release unlocked mutex") + logger.warning("Attempted to release unlocked mutex") def init(): @@ -1400,12 +1575,12 @@ def init(): max_num_devs = state_manager.get_max_num_midi_devs() max_num_chains = state_manager.get_num_zmop_chains() - logging.info(f"Initializing {num_devs_in} slots for MIDI input devices") + logger.info(f"Initializing {num_devs_in} slots for MIDI input devices") while len(devices_in) < num_devs_in: devices_in.append(None) while len(devices_in_mode) < num_devs_in: devices_in_mode.append(None) - logging.info(f"Initializing {num_devs_out} slots for MIDI output devices") + logger.info(f"Initializing {num_devs_out} slots for MIDI output devices") while len(devices_out) < num_devs_out: devices_out.append(None) devices_out_name.append(None) @@ -1435,8 +1610,7 @@ def start(sm): jclient.set_property_change_callback(cb_jack_property_change) jclient.activate() except Exception as e: - logger.error( - f"ZynAutoConnect ERROR: Can't connect with Jack Audio Server ({e})") + logger.error(f"Can't connect with Jack Audio Server ({e})") init() @@ -1446,7 +1620,7 @@ def start(sm): with open(f"{zynconf.config_dir}/sidechain.json", "r") as file: sidechain_map = json.load(file) except Exception as e: - logger.error(f"Cannot load sidechain map ({e})") + logger.error(f"Can't load sidechain map ({e})") # Create Lock object (Mutex) to avoid concurrence problems lock = Lock() @@ -1500,6 +1674,11 @@ def is_running(): return thread.is_alive() return False +def reset_xruns(): + """Reset the xrun counter""" + + global xruns + xruns = 0 def cb_jack_xrun(delayed_usecs: float): """Jack xrun callback @@ -1511,7 +1690,10 @@ def cb_jack_xrun(delayed_usecs: float): global xruns xruns += 1 logger.warning(f"Jack Audio XRUN! =>count: {xruns}, delay: {delayed_usecs}us") - state_manager.status_xrun = True + if delayed_usecs: + state_manager.status_xrun = 2 + else: + state_manager.status_xrun = 1 def cb_jack_property_change(subject, key, change): diff --git a/zynconf/zynthian_config.py b/zynconf/zynthian_config.py index e7526e529..5b3790d76 100755 --- a/zynconf/zynthian_config.py +++ b/zynconf/zynthian_config.py @@ -96,11 +96,11 @@ "57": "SELECT", "60": "SCREEN_MAIN_MENU", "62": "SCREEN_ADMIN", - "64": "SCREEN_AUDIO_MIXER", + "64": "SCREEN_MIXER", "65": "SCREEN_SNAPSHOT", "67": "SCREEN_ALSA_MIXER", "69": "SCREEN_MIDI_RECORDER", - "71": "SCREEN_ZYNPAD", + "71": "SCREEN_LAUNCHER", "72": "SCREEN_PATTERN_EDITOR", "74": "SCREEN_BANK", "76": "SCREEN_PRESET", diff --git a/zyngine/__init__.py b/zyngine/__init__.py index 604731d12..5dd29a590 100644 --- a/zyngine/__init__.py +++ b/zyngine/__init__.py @@ -3,6 +3,7 @@ "zynthian_controller", "zynthian_lv2", "zynthian_engine", + "zynthian_engine_audio_mixer", "zynthian_engine_sfz", "zynthian_engine_zynaddsubfx", "zynthian_engine_linuxsampler", @@ -15,9 +16,11 @@ "zynthian_engine_jalv", "zynthian_engine_sfizz", "zynthian_engine_alsa_mixer", + "zynthian_engine_tempo", "zynthian_engine_audioplayer", "zynthian_engine_sooperlooper", "zynthian_engine_inet_radio", + "zynthian_engine_clippy", "zynthian_engine_sysex", "zynthian_engine_midi_control", "zynthian_midi_filter", @@ -26,6 +29,7 @@ from zyngine.zynthian_controller import * from zyngine.zynthian_lv2 import * from zyngine.zynthian_engine import * +from zyngine.zynthian_engine_audio_mixer import * from zyngine.zynthian_engine_sfz import * from zyngine.zynthian_engine_zynaddsubfx import * from zyngine.zynthian_engine_linuxsampler import * @@ -38,9 +42,11 @@ from zyngine.zynthian_engine_jalv import * from zyngine.zynthian_engine_sfizz import * from zyngine.zynthian_engine_alsa_mixer import * +from zyngine.zynthian_engine_tempo import * from zyngine.zynthian_engine_audioplayer import * from zyngine.zynthian_engine_sooperlooper import * from zyngine.zynthian_engine_inet_radio import * +from zyngine.zynthian_engine_clippy import * from zyngine.zynthian_engine_sysex import * from zyngine.zynthian_engine_midi_control import * from zyngine.zynthian_midi_filter import * diff --git a/zyngine/ctrldev/mackiecontrol/mackiecontrol.yaml b/zyngine/ctrldev/mackiecontrol/mackiecontrol.yaml index 17bd701e3..14068e927 100644 --- a/zyngine/ctrldev/mackiecontrol/mackiecontrol.yaml +++ b/zyngine/ctrldev/mackiecontrol/mackiecontrol.yaml @@ -167,25 +167,28 @@ ccnum_buttons: command: mkc_globalview_set 52: name: display_namevalue - command: None + command: mkc_display_namevalue + 53: + name: display_smptebeats + command: mkc_display_smptebeats 54: name: function_f1 - command: cuia_SCREEN_PATTERN_EDITOR + command: cuia_SCREEN_ADMIN 55: name: function_f2 - command: cuia_SCREEN_ARRANGER + command: cuia_SCREEN_ALSA_MIXER 56: name: function_f3 - command: cuia_SCREEN_ALSA_MIXER + command: cuia_AUDIO_FILE_LIST 57: name: function_f4 command: cuia_SCREEN_MIDI_RECORDER 58: name: function_f5 - command: cuia_BANK_PRESET + command: None 59: name: function_f6 - command: cuia_CHAIN_CONTROL + command: None 60: name: function_f7 command: cuia_ALL_NOTES_OFF @@ -218,16 +221,16 @@ ccnum_buttons: command: mkc_viewassign_user 70: name: modify_shift - command: mkc_shiftassign_set + command: mkc_shiftassign_toggle 71: name: modify_option - command: None + command: cuia_MENU 72: name: modify_control - command: None + command: cuia_CHAIN_CONTROL 73: name: modify_alt - command: None + command: cuia_TOGGLE_ALT_MODE 74: name: automation_read command: None @@ -248,37 +251,37 @@ ccnum_buttons: command: None 80: name: utility_save - command: None + command: cuia_SCREEN_SNAPSHOT 81: name: utility_undo - command: None + command: cuia_SCREEN_ZS3 82: name: utility_cancel - command: None + command: cuia_BACK 83: name: utility_enter - command: None + command: ZYNSWITCH_3 84: name: transport_marker - command: cuia_SCREEN_MAIN_MENU + command: cuia_SCREEN_MIXER 85: name: transport_nudge - command: cuia_SCREEN_ADMIN + command: cuia_SCREEN_LAUNCHER 86: name: transport_cycle - command: cuia_SCREEN_AUDIO_MIXER + command: cuia_SCREEN_MAIN_MENU 87: name: transport_drop - command: cuia_SCREEN_SNAPSHOT + command: cuia_SCREEN_CONTROL 88: name: transport_replace - command: cuia_SCREEN_ZS3 + command: cuia_BANK_PRESET 89: name: transport_click - command: cuia_SCREEN_ALSA_MIXER + command: cuia_TEMPO 90: name: transport_solo - command: cuia_SCREEN_ZYNPAD + command: cuia_SCREEN_ADMIN 91: name: transport_frwd command: mkc_transport_frwd @@ -312,6 +315,12 @@ ccnum_buttons: 101: name: scrub command: cuia_BACK + 102: + name: key_102 + command: None + 103: + name: key_103 + command: None 104: name: fadertouch_0 command: mkc_fadertouch_0 diff --git a/zyngine/ctrldev/mackiecontrol/mackiecontrol_bcf2000_7m.yaml b/zyngine/ctrldev/mackiecontrol/mackiecontrol_bcf2000_7m.yaml index e51e9bfe3..8f396dc96 100644 --- a/zyngine/ctrldev/mackiecontrol/mackiecontrol_bcf2000_7m.yaml +++ b/zyngine/ctrldev/mackiecontrol/mackiecontrol_bcf2000_7m.yaml @@ -266,7 +266,7 @@ ccnum_buttons: command: cuia_SCREEN_ADMIN 86: name: transport_cycle - command: cuia_SCREEN_AUDIO_MIXER + command: cuia_SCREEN_MIXER 87: name: transport_drop command: cuia_SCREEN_SNAPSHOT diff --git a/zyngine/ctrldev/mackiecontrol/mackiecontrol_bcf2000_8.yaml b/zyngine/ctrldev/mackiecontrol/mackiecontrol_bcf2000_8.yaml index 307340fc2..520a617af 100644 --- a/zyngine/ctrldev/mackiecontrol/mackiecontrol_bcf2000_8.yaml +++ b/zyngine/ctrldev/mackiecontrol/mackiecontrol_bcf2000_8.yaml @@ -266,7 +266,7 @@ ccnum_buttons: command: cuia_SCREEN_ADMIN 86: name: transport_cycle - command: cuia_SCREEN_AUDIO_MIXER + command: cuia_SCREEN_MIXER 87: name: transport_drop command: cuia_SCREEN_SNAPSHOT diff --git a/zyngine/ctrldev/mackiecontrol/mackiecontrol_behringer_motor.yaml b/zyngine/ctrldev/mackiecontrol/mackiecontrol_behringer_motor.yaml index 39354a5c8..67067dff4 100644 --- a/zyngine/ctrldev/mackiecontrol/mackiecontrol_behringer_motor.yaml +++ b/zyngine/ctrldev/mackiecontrol/mackiecontrol_behringer_motor.yaml @@ -266,7 +266,7 @@ ccnum_buttons: command: cuia_SCREEN_ADMIN 86: name: transport_cycle - command: cuia_SCREEN_AUDIO_MIXER + command: cuia_SCREEN_MIXER 87: name: transport_drop command: cuia_SCREEN_SNAPSHOT diff --git a/zyngine/ctrldev/mackiecontrol/mackiecontrol_xtouch.yaml b/zyngine/ctrldev/mackiecontrol/mackiecontrol_xtouch.yaml index 28e0fc1ed..f390f68d7 100644 --- a/zyngine/ctrldev/mackiecontrol/mackiecontrol_xtouch.yaml +++ b/zyngine/ctrldev/mackiecontrol/mackiecontrol_xtouch.yaml @@ -266,7 +266,7 @@ ccnum_buttons: command: cuia_SCREEN_ADMIN 86: name: transport_cycle - command: cuia_SCREEN_AUDIO_MIXER + command: cuia_SCREEN_MIXER 87: name: transport_drop command: cuia_SCREEN_SNAPSHOT diff --git a/zyngine/ctrldev/zynthian_ctrldev_akai_apc_40_mk2.py b/zyngine/ctrldev/zynthian_ctrldev_akai_apc_40_mk2.py new file mode 100644 index 000000000..c8d16d70d --- /dev/null +++ b/zyngine/ctrldev/zynthian_ctrldev_akai_apc_40_mk2.py @@ -0,0 +1,666 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian Control Device Driver +# +# Zynthian Control Device Driver for "Akai APC 40 mk2" +# +# Copyright (C) 2025 Brian Walton +# API: https://cdn.inmusicbrands.com/akai/attachments/apc40II/APC40Mk2_Communications_Protocol_v1.2.pdf +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import logging +from time import monotonic, sleep + +# Zynthian specific modules +from zyngine.ctrldev.zynthian_ctrldev_base import * +from zyncoder.zyncore import lib_zyncore +from zynlibs.zynseq import zynseq +from zyngui import zynthian_gui_config +from zyngine.zynthian_chain_manager import MAX_NUM_MIDI_CHANS +from zyngine.zynthian_signal_manager import zynsigman +from zyngine.zynthian_audio_recorder import zynthian_audio_recorder +from zyngine.zynthian_engine_audioplayer import zynthian_engine_audioplayer + +BOLD_PRESS_TIME = 0.4 + +ENC_MODE_PAN = 0 +ENC_MODE_SENDS = 1 +ENC_MODE_USER = 2 + +LED_CLIP_LAUNCH = 0x00 # First clip launcher RGB LED (0x00-0x27) +LED_RECORD_ARM = 0x30 # Record arm LED (Tracks: MIDI channel 0-7. 0=off, 1-127=Rred) +LED_SOLO = 0x31 # Solo LED (Tracks: MIDI channel 0-7. 0=off, 1-127=blue) +LED_ACTIVATOR = 0x32 # Activator LED (Tracks: MIDI channel 0-7. 0=off, 1-127=orange) +LED_TRACK_SEL = 0x33 # Track select LED (Tracks: MIDI channel 0-7. 0=off, 1-127=orange) +LED_CLIP_STOP = 0x34 # Clip stop LED (Tracks: MIDI channel 0-7. 0=off, 1=on, 2=flashing, 3-127=on) +LED_DEVICE_LEFT = 0x3A # Device left LED (Tracks: MIDI channel 0-8. 0=off, 1-127=on) +LED_DEVICE_RIGHT = 0x3B # Device right LED (Tracks: MIDI channel 0-8. 0=off, 1-127=on) +LED_BANK_LEFT = 0x3C # Bank left LED (Tracks: MIDI channel 0-8. 0=off, 1-127=on) +LED_BANK_RIGHT = 0x3D # Bank right LED (Tracks: MIDI channel 0-8. 0=off, 1-127=on) +LED_DEVICE_ON = 0x3E # Device on/off LED (Tracks: MIDI channel 0-8. 0=off, 1-127=on) +LED_DEVICE_LOCK = 0x3F # Device lock LED (Tracks: MIDI channel 0-8. 0=off, 1-127=on) +LED_DEVICE_VIEW = 0x40 # Clip/dev. view LED (Tracks: MIDI channel 0-8. 0=off, 1-127=on) +LED_DETAIL_VIEW = 0x41 # Detail view LED (Tracks: MIDI channel 0-8. 0=off, 1-127=on) +LED_CROSSOVER_AB = 0x42 # (Track: MIDI channel 0-7. 0=off, 1=Yellow, 2-127=Orange) NO LED ON DEVICE! +LED_MASTER = 0x50 # Master LED (0=off, 1-127=on) +LED_SCENE_LAUNCH_1 = 0x52 # Launch scene 1 RGB LED +LED_SCENE_LAUNCH_2 = 0x53 # Launch scene 2 RGB LED +LED_SCENE_LAUNCH_3 = 0x54 # Launch scene 3 RGB LED +LED_SCENE_LAUNCH_4 = 0x55 # Launch scene 4 RGB LED +LED_SCENE_LAUNCH_5 = 0x56 # Launch scene 5 RGB LED +LED_PAN = 0x57 # Pan LED (0=off, 1-127=on) +LED_SENDS = 0x58 # Sends LED (0=off, 1-127=on) +LED_USER = 0x59 # User LED (0=off, 1-127=on) +LED_METRONOME = 0x5A # Metronome LED (0=off, 1-127=on) +LED_PLAY = 0x5B # Play LED (0=off, 1-127=on) +LED_STOP = 0x5C # STOP LED (0=off, 1-127=on) +LED_RECORD = 0x5D # Record LED (0=off, 1-127=on) + +BTN_STOP_ALL_CLIPS = 0x51 # Stop all clips +BTN_UP = 0x5E # Up button +BTN_DOWN = 0x5F # Down button +BTN_RIGHT = 0x60 # Right button +BTN_LEFT = 0x61 # Left button +BTN_SHIFT = 0x62 # Shift button +BTN_TAP_TEMPO = 0x63 # Tap tempo button +BTN_NUDGE_DOWN = 0x64 # Nudge - button +BTN_NUDGE_UP = 0x65 # Nudge + button +BTN_SESSION = 0x66 # Session record button +BTN_BANK = 0x67 # Bank button + +CC_FADER = 0x07 # Fader slider (Tracks: MIDI channel 0-7) +CC_TEMPO = 0x0D # Tempo knob (Relative) +CC_MAIN_FADER = 0x0E # Main fader slider +CC_CROSS_FADER = 0x0F # A/B cross fader +CC_DEVICE_1 = 0x10 # Device control knob (Tracks: MIDI channel 0-7) +CC_DEVICE_2 = 0x11 # Device control knob (Tracks: MIDI channel 0-7) +CC_DEVICE_3 = 0x12 # Device control knob (Tracks: MIDI channel 0-7) +CC_DEVICE_4 = 0x13 # Device control knob (Tracks: MIDI channel 0-7) +CC_DEVICE_5 = 0x14 # Device control knob (Tracks: MIDI channel 0-7) +CC_DEVICE_6 = 0x15 # Device control knob (Tracks: MIDI channel 0-7) +CC_DEVICE_7 = 0x16 # Device control knob (Tracks: MIDI channel 0-7) +CC_DEVICE_8 = 0x17 # Device control knob (Tracks: MIDI channel 0-7) +CC_LED_RING_1 = 0x18 # LED ring type (Tracks: MIDI channel 0-7) +CC_LED_RING_2 = 0x19 # LED ring type (Tracks: MIDI channel 0-7) +CC_LED_RING_3 = 0x1A # LED ring type (Tracks: MIDI channel 0-7) +CC_LED_RING_4 = 0x1B # LED ring type (Tracks: MIDI channel 0-7) +CC_LED_RING_5 = 0x1C # LED ring type (Tracks: MIDI channel 0-7) +CC_LED_RING_6 = 0x1D # LED ring type (Tracks: MIDI channel 0-7) +CC_LED_RING_7 = 0x1E # LED ring type (Tracks: MIDI channel 0-7) +CC_LED_RING_8 = 0x1F # LED ring type (Tracks: MIDI channel 0-7) +CC_CUE_LEVEL = 0x2F # Cue level knob +CC_TRACK_1 = 0x30 # Track encoder knob +CC_TRACK_2 = 0x31 # Track encoder knob +CC_TRACK_3 = 0x32 # Track encoder knob +CC_TRACK_4 = 0x33 # Track encoder knob +CC_TRACK_5 = 0x34 # Track encoder knob +CC_TRACK_6 = 0x35 # Track encoder knob +CC_TRACK_7 = 0x36 # Track encoder knob +CC_TRACK_8 = 0x37 # Track encoder knob +CC_TRACK_LED_RING_1 = 0x38 # Track encoder LED ring type +CC_TRACK_LED_RING_2 = 0x39 # Track encoder LED ring type +CC_TRACK_LED_RING_3 = 0x3A # Track encoder LED ring type +CC_TRACK_LED_RING_4 = 0x3B # Track encoder LED ring type +CC_TRACK_LED_RING_5 = 0x3C # Track encoder LED ring type +CC_TRACK_LED_RING_6 = 0x3D # Track encoder LED ring type +CC_TRACK_LED_RING_7 = 0x3E # Track encoder LED ring type +CC_TRACK_LED_RING_8 = 0x3F # Track encoder LED ring type +CC_FOOTSWITCH = 0x40 # External foot switch + +# MIDI channels set mode of RGB buttons +RGB_MODE_PRIMARY = 0x00 # Show RGB LED primary colour +RGB_MODE_ONE_SHOT_24= 0x01 # Show RGB LED secondary colour - oneshot 1/24 +RGB_MODE_ONE_SHOT_16= 0x02 # Show RGB LED secondary colour - oneshot 1/16 +RGB_MODE_ONE_SHOT_8 = 0x03 # Show RGB LED secondary colour - oneshot 1/8 +RGB_MODE_ONE_SHOT_4 = 0x04 # Show RGB LED secondary colour - oneshot 1/4 +RGB_MODE_ONE_SHOT_2 = 0x05 # Show RGB LED secondary colour - oneshot 1/2 +RGB_MODE_PULSE_24 = 0x06 # Show RGB LED secondary colour - pulsing 1/24 +RGB_MODE_PULSE_16 = 0x07 # Show RGB LED secondary colour - pulsing 1/24 +RGB_MODE_PULSE_8 = 0x08 # Show RGB LED secondary colour - pulsing 1/24 +RGB_MODE_PULSE_4 = 0x09 # Show RGB LED secondary colour - pulsing 1/24 +RGB_MODE_PULSE_2 = 0x0A # Show RGB LED secondary colour - pulsing 1/24 +RGB_MODE_BLINK_24 = 0x0B # Show RGB LED secondary colour - blinking 1/24 +RGB_MODE_BLINK_16 = 0x0C # Show RGB LED secondary colour - blinking 1/24 +RGB_MODE_BLINK_8 = 0x0D # Show RGB LED secondary colour - blinking 1/24 +RGB_MODE_BLINK_4 = 0x0E # Show RGB LED secondary colour - blinking 1/24 +RGB_MODE_BLINK_2 = 0x0F # Show RGB LED secondary colour - blinking 1/24 + +# LED ring types +LED_RING_TYPE_OFF = 0x00 # Off +LED_RING_TYPE_SINGLE= 0x01 # Single ring +LED_RING_TYPE_VOLUME= 0x02 # Volume style +LED_RING_TYPE_PAN = 0x03 # Pan style + + +class zynthian_ctrldev_akai_apc_40_mk2(zynthian_ctrldev_zynpad, zynthian_ctrldev_zynmixer): + + dev_ids = ["APC40 mkII IN 1"] + driver_name = "APC40 mk2" + driver_description = "Interface Akai APC40 with mixer, launcher and more..." + + # Function to initialise class + def __init__(self, state_manager, idev_in, idev_out=None): + super().__init__(state_manager, idev_in, idev_out) + self.cols = 8 + self.rows = 5 + + self.sysex_manufacturer_id = None + self.sysex_product_model_id = None + self.sysex_dev_id = None + self._shift = False # True whilst shift button is pressed + self._send = False # True whilst send button is pressed + self.send = 0 # Index of send to adjust + self._user = False # True whilst user button is pressed + self.user = 0 # Index of (fav) chain controller to adjust + self.enc_mode = ENC_MODE_PAN # Chain encoder mode + self.last_cc_send = 0 # Time of last sent feedback CC - used to avoid feedback interference + self.mixer_toggle = False + self.last_press = None + self.ccnum2zctrls = {} + + # Send the SysEx universal device enquiry + def send_sysex_universal_device_enquiry(self, chan=0): + self.sysex_dev_id = None + msg = bytes.fromhex(f"F0 7E {chan:02x} 06 01 F7") + logging.debug("SYSEX UNIVERSAL DEVICE ENQUIRY: " + msg.hex(" ")) + lib_zyncore.dev_send_midi_event(self.idev_out, msg, len(msg)) + + def send_sysex(self, msg_id, data): + if self.idev_out is not None and self.sysex_dev_id is not None: + size = len(data) + header_data = bytes([self.sysex_manufacturer_id, self.sysex_dev_id, self.sysex_product_model_id, msg_id]) + msg = bytes.fromhex(f"F0 {header_data.hex(' ')} {size//0xFF:02x} {size%0xFF:02x} {data.hex(' ')} F7") + logging.debug("SYSEX MESSAGE: " + msg.hex(" ")) + lib_zyncore.dev_send_midi_event(self.idev_out, msg, len(msg)) + + def send_sysex_set_mode(self, mode): + self.send_sysex(0x60, bytes([mode, 1, 1, 1])) + + def init(self): + self.send_sysex_universal_device_enquiry(0) + sleep(0.05) + self.last_press = None # Time of last button press used for (simple single-button) bold press detection + self.mixer_toggle = False + self.set_scroll_mode(SCROLL_MODE_GUI_SEL) + self.refresh() + self.on_active_chain() + self.set_enc_mode() + super().init() + zynsigman.register_queued(zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_STATE, self.on_audio_rec) + zynsigman.register_queued(zynsigman.S_AUDIO_PLAYER, zynthian_engine_audioplayer.SS_AUDIO_PLAYER_STATE, self.on_audio_play) + zynsigman.register_queued(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_METRO, self.on_metronome) + zynsigman.register_queued(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_SCREEN, self.on_gui_show_screen) + + def end(self): + super().end() + zynsigman.unregister(zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_STATE, self.on_audio_rec) + zynsigman.unregister(zynsigman.S_AUDIO_PLAYER, zynthian_engine_audioplayer.SS_AUDIO_PLAYER_STATE, self.on_audio_play) + zynsigman.unregister(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_METRO, self.on_metronome) + zynsigman.unregister(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_SCREEN, self.on_gui_show_screen) + + def light_off(self): + for led in range(0x28): + lib_zyncore.dev_send_note_off(self.idev_out, 0, led, 0) + + def on_gui_show_screen(self, screen): + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, BTN_SESSION, 0x0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, BTN_BANK, 0x0) + match screen: + case "launcher": + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, BTN_SESSION, 0x1) + case "presets": + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, BTN_BANK, 0x1) + case "banks": + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, BTN_BANK, 0x1) + + def update_mixer_strip(self, chan, symbol, value, mixbus=False): + if symbol == "balance" and self.enc_mode == ENC_MODE_PAN: + if mixbus and chan == 0: + # Main chain + pass + else: + try: + now = monotonic() + if now < self.last_cc_send + 0.1: + return + pos = self.chain_manager.get_pos_by_mixer_chan(chan, mixbus) -self.scroll_h + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, pos + 48, int((value + 1.0) * 63)) + except: + pass + elif symbol == "solo": + if mixbus and chan == 0: + return # No control for main mixbus + try: + pos = self.chain_manager.get_pos_by_mixer_chan(chan, mixbus) - self.scroll_h + if 0 <= pos < self.cols: + lib_zyncore.dev_send_note_on(self.idev_out, pos, LED_SOLO, value) + except TypeError: + pass + elif symbol == "mute": + if mixbus and chan == 0: + return # No control for main mixbus + try: + pos = self.chain_manager.get_pos_by_mixer_chan(chan, mixbus) - self.scroll_h + if 0 <= pos < self.cols: + lib_zyncore.dev_send_note_on(self.idev_out, pos, LED_ACTIVATOR, value) + except TypeError: + pass + elif symbol == "record": + if mixbus and chan == 0: + return # No control for main mixbus + try: + pos = self.chain_manager.get_pos_by_mixer_chan(chan, mixbus) - self.scroll_h + if 0 <= pos < self.cols: + lib_zyncore.dev_send_note_on(self.idev_out, pos, LED_RECORD_ARM, value) + except TypeError: + pass + + def on_active_chain(self, active_chain_id=None): + if active_chain_id is None: + active_chain_id = self.chain_manager.active_chain.chain_id + super().on_active_chain(active_chain_id) + for pos in range(self.cols): + lib_zyncore.dev_send_note_on(self.idev_out, pos, LED_TRACK_SEL, 0) + if active_chain_id == 0: + lib_zyncore.dev_send_note_on(self.idev_out, 0, LED_MASTER, 1) + self.update_device_encoders() + return + pos = self.chain_manager.get_chain_index(active_chain_id) + if pos is None: + return + pos = max(0, pos - self.scroll_h) + lib_zyncore.dev_send_note_on(self.idev_out, 0, LED_MASTER, 0) + lib_zyncore.dev_send_note_on(self.idev_out, pos, LED_TRACK_SEL, 1) + self.update_device_encoders() + + def refresh(self): + super().refresh() + for col in range(min(self.cols, len(self.chain_ids_filtered))): + pos = self.scroll_h + col + chain_id = self.chain_ids_filtered[pos] + if chain_id == 0: + continue + proc = self.chain_manager.chains[chain_id].zynmixer_proc + if proc: + for symbol in ("mute", "solo", "record"): + mixbus = proc.eng_code == "MR" + value = proc.controllers_dict[symbol].value + self.update_mixer_strip(pos, symbol, value, mixbus) + else: + lib_zyncore.dev_send_note_on(self.idev_out, col, LED_SOLO, 0) + lib_zyncore.dev_send_note_on(self.idev_out, col, LED_ACTIVATOR, 0) + lib_zyncore.dev_send_note_on(self.idev_out, col, LED_RECORD_ARM, 0) + #self.set_enc_mode(self.enc_mode) + + def on_audio_rec(self, state): + lib_zyncore.dev_send_note_on(self.idev_out, 0, LED_RECORD, state) + + def on_audio_play(self, handle, state): + try: + if handle == self.state_manager.audio_player.handle: + lib_zyncore.dev_send_note_on(self.idev_out, 0, LED_PLAY, int(state)) + except: + pass + + def on_metronome(self, mode, volume): + lib_zyncore.dev_send_note_on(self.idev_out, 0, LED_METRONOME, mode) + + def set_enc_mode(self, mode=None): + if mode is not None and mode <= ENC_MODE_USER: + self.enc_mode = mode + lib_zyncore.dev_send_note_on(self.idev_out, 0, LED_PAN, self.enc_mode == ENC_MODE_PAN) + lib_zyncore.dev_send_note_on(self.idev_out, 0, LED_SENDS, self.enc_mode == ENC_MODE_SENDS) + lib_zyncore.dev_send_note_on(self.idev_out, 0, LED_USER, self.enc_mode == ENC_MODE_USER) + self.update_track_encoders() + # Update device button leds + for led in range(LED_DEVICE_LEFT, LED_DEVICE_VIEW + 1): + lib_zyncore.dev_send_note_off(self.idev_out, 0, led, 0) + if self.enc_mode == ENC_MODE_SENDS: + lib_zyncore.dev_send_note_on(self.idev_out, 0, LED_DEVICE_LEFT + self.send, 1) + elif self.enc_mode == ENC_MODE_USER: + lib_zyncore.dev_send_note_on(self.idev_out, 0, LED_DEVICE_LEFT + self.user, 1) + + # Send track knob values + def update_track_encoders(self): + if self.enc_mode == ENC_MODE_PAN: + for i in range(8): + pos = self.scroll_h + i + cc = CC_TRACK_1 + i + val = int((self.get_mixer_param("balance", pos) + 1) * 64) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, cc, val) + try: + self.ccnum2zctrls[cc].reset_send_value_cb() + del self.ccnum2zctrls[cc] + except: + pass + elif self.enc_mode == ENC_MODE_SENDS: + for i in range(8): + pos = self.scroll_h + i + cc = CC_TRACK_1 + i + send_id = self.chain_manager.get_send_id(self.send) + if send_id is not None: + val = int(self.get_mixer_param(f"send_{send_id}_level", pos) * 127) + else: + val = 0 + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, cc, val) + try: + self.ccnum2zctrls[cc].reset_send_value_cb() + del self.ccnum2zctrls[cc] + except: + pass + elif self.enc_mode == ENC_MODE_USER: + for i in range(8): + pos = self.scroll_h + i + cc = CC_TRACK_1 + i + # Use first chain zctrl (favorite) + try: + self.ccnum2zctrls[cc] = self.get_filtered_chain_by_index(pos).zctrls[self.user] + self.ccnum2zctrls[cc].set_send_value_cb(self.send_encoder_feedback) + val = self.ccnum2zctrls[cc].get_ctrl_midi_val() + except: + val = 0 + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, cc, val) + + # Send device knob values + def update_device_encoders(self): + # Reset zctrl callbacks + for cc in list(self.ccnum2zctrls.keys()): + self.ccnum2zctrls[cc].reset_send_value_cb() + del self.ccnum2zctrls[cc] + + # Get absolute MIDI learned zctrls + key_base = self.idev << 16 + for key in list(self.chain_manager.absolute_midi_cc_binding): + if key_base == (key & 0xFFFF00): + cc = key & 0x7F + #logging.debug(f"FOUND ABSOLUTE ZCTRL {key:06x} == {key_base:06x}") + self.ccnum2zctrls[cc] = self.chain_manager.absolute_midi_cc_binding[key][0] + # Get chain MIDI learned zctrls + key_base = self.chain_manager.active_chain.chain_id << 16 + for key in list(self.chain_manager.chain_midi_cc_binding): + cc = key & 0x7F + if key_base == (key & 0xFFFF00) and cc not in self.ccnum2zctrls: + #logging.debug(f"FOUND CHAIN ZCTRL {key:06x} == {key_base:06x}") + self.ccnum2zctrls[cc] = self.chain_manager.chain_midi_cc_binding[key][0] + # Send update to the 8 device knobs + for i in range(8): + cc = CC_DEVICE_1 + i + try: + self.ccnum2zctrls[cc].set_send_value_cb(self.send_encoder_feedback) + val = self.ccnum2zctrls[cc].get_ctrl_midi_val() + except: + val = 0 + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, cc, val) + + def send_encoder_feedback(self, zctrl): + for cc, _zctrl in self.ccnum2zctrls.items(): + if _zctrl == zctrl: + val = zctrl.get_ctrl_midi_val() + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, cc, val) + + def midi_event(self, ev): + def relative_to_signed(v): + return v if v < 64 else v - 128 + + # SysEx + if ev[0] == 0xF0: + logging.info(f"Received SysEx => {ev.hex(' ')}") + if ev[3] == 0x6 and ev[4] == 0x2: + self.sysex_manufacturer_id = ev[5] + self.sysex_product_model_id = ev[6] + self.sysex_dev_id = ev[13] + # Set Mode 1 + self.send_sysex_set_mode(0x41) + return True + + if self.state_manager.power_save_mode: + return True + + evtype = (ev[0] >> 4) & 0x0F + chan = ev[0] & 0x0f + now = monotonic() + if evtype == 0xb: + cc = ev[1] + val = ev[2] + if cc == CC_MAIN_FADER: + self.set_mixer_param("level", -1, val / 127.0) + elif cc == CC_FADER: + pos = self.scroll_h + chan + self.set_mixer_param("level", pos, val / 127.0) + elif cc == CC_CUE_LEVEL: + dval = relative_to_signed(val) + self.nudge_mixer_param("balance", -1, dval) + elif cc == CC_TEMPO: + dval = relative_to_signed(val) + if self._shift: + self.zynseq.zctrl_metro_volume.nudge(dval**3) + else: + self.zynseq.nudge_tempo(dval) + elif CC_TRACK_1 <= cc <= CC_TRACK_8: + pos = self.scroll_h + cc - CC_TRACK_1 + if self.enc_mode == ENC_MODE_PAN: + self.set_mixer_param("balance", pos, val / 64.0 - 1.0) + self.last_cc_send = now + elif self.enc_mode == ENC_MODE_SENDS: + send_id = self.chain_manager.get_send_id(self.send) + if send_id is not None: + self.set_mixer_param(f"send_{send_id}_level", pos, val / 127.0) + elif self.enc_mode == ENC_MODE_USER: + # Use first chain zctrl (favorite) + try: + user_zctrl = self.get_filtered_chain_by_index(pos).zctrls[self.user] + user_zctrl.midi_control_change(val) + except: + pass + # Send CC to chains and midi_learn subsystem + elif CC_DEVICE_1 <= cc <= CC_DEVICE_8: + #logging.debug(f"DEVICE CC => cc={cc}, val={val}") + if not self.state_manager.midi_learn_zctrl: + self.chain_manager.midi_control_change(self.idev, 0, cc, val) + zynsigman.send_queued(zynsigman.S_MIDI, zynsigman.SS_MIDI_CC, + izmip=self.idev, chan=0, num=cc, val=val) + + elif evtype == 8: + # Note off + note = ev[1] + not_enc_mode = not (self._send or self._user) + if note == BTN_SHIFT: + self._shift = False + if self.enc_mode == ENC_MODE_SENDS: + self.update_track_encoders() + elif note == LED_SENDS: + self._send = False + elif note == LED_USER: + self._user = False + elif note == BTN_STOP_ALL_CLIPS: + if self.last_press and now > self.last_press + BOLD_PRESS_TIME: + # PANIC! + self.state_manager.all_notes_off() + else: + for midi_chan in range(MAX_NUM_MIDI_CHANS + 1): + for phrase in range(self.zynseq.phrases): + self.zynseq.libseq.setPlayState(self.zynseq.scene, phrase, midi_chan, zynseq.SEQ_STOPPED) + self.last_press = None + + # Navigation CUIAs: Arrows, BACK, SELECT, etc. + elif note == LED_DEVICE_ON and not_enc_mode: + #self.state_manager.send_cuia("ZYNSWITCH", (1, "R")) + pass + elif note == LED_DEVICE_LOCK and not_enc_mode: + self.state_manager.send_cuia("ZYNSWITCH", (3, "R")) + elif note == LED_DEVICE_VIEW: + pass + elif note == LED_DETAIL_VIEW: + pass + + elif evtype == 9: + # Note on + note = ev[1] + # Clip launcher pads + if note < 0x30: + # Launcher pad => sel.scroll_h = self.scroll_h + pos = self.scroll_h + note % 8 + row = note // 8 + midi_chan = self.get_filtered_midi_chan_by_index(pos) + if midi_chan is None: + return + phrase = self.rows - 1 - row + self.scroll_v + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, midi_chan) + # Scene buttons => Phrase launcher + elif LED_SCENE_LAUNCH_1 <= note <= LED_SCENE_LAUNCH_5: + phrase = note - LED_SCENE_LAUNCH_1 + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, 32) + # Track select button => Chain select + elif note == LED_TRACK_SEL: + try: + id = self.chain_ids_filtered[self.scroll_h + chan] + self.chain_manager.set_active_chain_by_id(id) + except: + pass + elif note == LED_MASTER: + self.chain_manager.set_active_chain_by_id(0) + # Track flag buttons + elif note == LED_SOLO: + pos = self.scroll_h + chan + self.toggle_mixer_param("solo", pos) + elif note == LED_ACTIVATOR: + pos = self.scroll_h + chan + self.toggle_mixer_param("mute", pos) + elif note == LED_RECORD_ARM: + pos = self.scroll_h + chan + self.toggle_mixer_param("record", pos) + elif note == LED_CLIP_STOP: + pos = self.scroll_h + chan + midi_chan = self.get_filtered_midi_chan_by_index(pos) + for phrase in range(self.zynseq.phrases): + self.zynseq.libseq.setPlayState(self.zynseq.scene, phrase, midi_chan, zynseq.SEQ_STOPPING) + # Global buttons + elif note == BTN_STOP_ALL_CLIPS: + self.last_press = now + elif note == LED_PAN: + self.set_enc_mode(ENC_MODE_PAN) + elif note == LED_SENDS: + self.set_enc_mode(ENC_MODE_SENDS) + self._send = True + elif note == LED_USER: + self.set_enc_mode(ENC_MODE_USER) + self._user = True + elif note == BTN_SHIFT: + self._shift = True + elif note == BTN_TAP_TEMPO: + self.zynseq.tap_tempo() + elif note == LED_PLAY: + self.state_manager.toggle_audio_player() + elif note == LED_RECORD: + self.state_manager.audio_recorder.toggle_recording() + elif note == LED_METRONOME: + if self._shift: + self.state_manager.send_cuia("TOGGLE_SCREEN", ("tempo",)) + else: + self.zynseq.zctrl_metro_mode.toggle() + elif note == BTN_NUDGE_DOWN: + self.zynseq.nudge_tempo(-0.1) + elif note == BTN_NUDGE_UP: + self.zynseq.nudge_tempo(+0.1) + + # Navigation CUIAs: Arrows, BACK, SELECT, etc. + elif note == BTN_UP: + self.state_manager.send_cuia("ARROW_UP") + elif note == BTN_DOWN: + self.state_manager.send_cuia("ARROW_DOWN") + elif note == BTN_LEFT: + self.state_manager.send_cuia("ARROW_LEFT") + elif note == BTN_RIGHT: + self.state_manager.send_cuia("ARROW_RIGHT") + elif LED_DEVICE_LEFT <= note <= LED_DETAIL_VIEW: + idx = note - LED_DEVICE_LEFT + if self._send: + self.send = idx + self.set_enc_mode(ENC_MODE_SENDS) + elif self._user: + self.user = idx + self.set_enc_mode(ENC_MODE_USER) + elif note == LED_DEVICE_ON: + self.state_manager.send_cuia("BACK") + #self.state_manager.send_cuia("ZYNSWITCH", (1, "P")) + elif note == LED_DEVICE_LOCK: + self.state_manager.send_cuia("ZYNSWITCH", (3, "P")) + elif note == LED_DETAIL_VIEW: + if self._send: + self.send = 7 + self.set_enc_mode(ENC_MODE_SENDS) + else: + pass + elif note == BTN_SESSION: + if zynthian_gui_config.zyngui.screens["mixer"].launcher_mode: + self.state_manager.send_cuia("SCREEN_MIXER") + else: + self.state_manager.send_cuia("SCREEN_LAUNCHER") + elif note == BTN_BANK: + self.state_manager.send_cuia("BANK_PRESET") + else: + logging.debug(f"UNCATCHED NOTE-ON => {note:02x}") + + return True + + def update_pad(self, row, col, pad_info): + if col < self.cols: + note = (4 - row) * 8 + col + elif col == self.phrase_launcher_col: + note = row + LED_SCENE_LAUNCH_1 + else: + return + led_mode = RGB_MODE_PRIMARY + led_colour = 0 + try: + state = pad_info["state"] + try: + group = pad_info["group"] + except: + group = 32 + if group <= MAX_NUM_MIDI_CHANS: + led_colour_primary = zynthian_gui_config.LAUNCHER_COLOUR[group]["apc"] + if pad_info["repeat"] == 0: + pass + elif state == zynseq.SEQ_STOPPED: + if not pad_info["empty"]: + led_colour = led_colour_primary + elif state == zynseq.SEQ_PLAYING: + lib_zyncore.dev_send_note_on(self.idev_out, RGB_MODE_PRIMARY, note, led_colour_primary) + led_colour = zynthian_gui_config.LAUNCHER_PLAYING_COLOUR["apc"] + led_mode = RGB_MODE_PULSE_2 + elif state in [zynseq.SEQ_STOPPING, zynseq.SEQ_STOPPING_SYNC]: + lib_zyncore.dev_send_note_on(self.idev_out, RGB_MODE_PRIMARY, note, led_colour_primary) + led_colour = zynthian_gui_config.LAUNCHER_STOPPING_COLOUR["apc"] + led_mode = RGB_MODE_BLINK_4 + elif state == zynseq.SEQ_STARTING: + lib_zyncore.dev_send_note_on(self.idev_out, RGB_MODE_PRIMARY, note, led_colour_primary) + led_colour = zynthian_gui_config.LAUNCHER_STARTING_COLOUR["apc"] + led_mode = RGB_MODE_BLINK_8 + elif state == zynseq.SEQ_CHILD_PLAYING: + lib_zyncore.dev_send_note_on(self.idev_out, RGB_MODE_PRIMARY, note, led_colour_primary) + led_colour = zynthian_gui_config.LAUNCHER_PLAYING_COLOUR["apc"] + led_mode = RGB_MODE_PULSE_2 + elif state == zynseq.SEQ_CHILD_STOPPING: + lib_zyncore.dev_send_note_on(self.idev_out, RGB_MODE_PRIMARY, note, led_colour_primary) + led_colour = zynthian_gui_config.LAUNCHER_STOPPING_COLOUR["apc"] + led_mode = RGB_MODE_BLINK_4 + except: + pass + lib_zyncore.dev_send_note_on(self.idev_out, led_mode, note, led_colour) diff --git a/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25.py b/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25.py index d46ca8309..d96eecd6b 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25.py +++ b/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25.py @@ -357,7 +357,7 @@ def cc_change(self, ccnum, ccval): # Update sequence's chain volume elif ccnum == KNOB_2: self._show_screen_briefly( - screen="audio_mixer", cuia="SCREEN_AUDIO_MIXER", timeout=1500) + screen="mixer", cuia="SCREEN_MIXER", timeout=1500) chain_id = self._get_chain_id_by_sequence( self._zynseq.bank, self._selected_seq) chain = self._chain_manager.chains.get(chain_id) diff --git a/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25_mk2.py b/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25_mk2.py index 63d894d01..7b490ff5c 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25_mk2.py +++ b/zyngine/ctrldev/zynthian_ctrldev_akai_apc_key25_mk2.py @@ -178,7 +178,7 @@ class COLORS: FN_MUTE = 0x04 FN_REC_ARM = 0x05 FN_SELECT = 0x06 -FN_SCENE = 0x07 +FN_PHRASE = 0x07 FN_SEQUENCE_MANAGER = 0x08 FN_COPY_SEQUENCE = 0x09 FN_MOVE_SEQUENCE = 0x0A @@ -269,8 +269,8 @@ def __init__(self, state_manager, leds: FeedbackLEDs, colors: COLORS): self._btn_actions = { BTN_OPT_ADMIN: ("MENU", "SCREEN_ADMIN"), - BTN_MIX_LEVEL: ("SCREEN_AUDIO_MIXER", "SCREEN_ALSA_MIXER"), - BTN_CTRL_PRESET: ("SCREEN_CONTROL", "PRESET", "SCREEN_BANK"), + BTN_MIX_LEVEL: ("SCREEN_MIXER", "SCREEN_ALSA_MIXER"), + BTN_CTRL_PRESET: ("SCREEN_CONTROL", "PRESET", "SCREEN_scene"), BTN_ZS3_SHOT: ("SCREEN_ZS3", "SCREEN_SNAPSHOT"), BTN_PAD_STEP: ("SCREEN_ZYNPAD", "SCREEN_PATTERN_EDITOR"), BTN_METRONOME: ("TEMPO",), @@ -384,12 +384,12 @@ def on_screen_change(self, screen): "option": (BTN_OPT_ADMIN, 0), "main_menu": (BTN_OPT_ADMIN, 0), "admin": (BTN_OPT_ADMIN, 1), - "audio_mixer": (BTN_MIX_LEVEL, 0), + "mixer": (BTN_MIX_LEVEL, 0), "alsa_mixer": (BTN_MIX_LEVEL, 1), "control": (BTN_CTRL_PRESET, 0), "engine": (BTN_CTRL_PRESET, 0), "preset": (BTN_CTRL_PRESET, 1), - "bank": (BTN_CTRL_PRESET, 1), + "scene": (BTN_CTRL_PRESET, 1), "zs3": (BTN_ZS3_SHOT, 0), "snapshot": (BTN_ZS3_SHOT, 1), "zynpad": (BTN_PAD_STEP, 0), @@ -461,7 +461,7 @@ def __init__(self, state_manager, leds: FeedbackLEDs): self._is_shifted = False self._knobs_function = FN_VOLUME self._track_buttons_function = FN_SELECT - self._chains_bank = 0 + self._chains_scene = 0 active_chain = self._chain_manager.get_active_chain() self._active_chain = active_chain.chain_id if active_chain else 0 @@ -484,20 +484,20 @@ def refresh(self): FN_MUTE: BTN_SOFT_KEY_MUTE, FN_SOLO: BTN_SOFT_KEY_SOLO, FN_SELECT: BTN_SOFT_KEY_SELECT, - FN_SCENE: BTN_SOFT_KEY_REC_ARM, + FN_PHRASE: BTN_SOFT_KEY_REC_ARM, }[self._track_buttons_function] self._leds.led_on(btn) - # Clips bank selection - btn = BTN_LEFT if self._chains_bank == 0 else BTN_RIGHT + # Clips scene selection + btn = BTN_LEFT if self._chains_scene == 0 else BTN_RIGHT self._leds.led_on(btn) # Otherwise, show current function status else: - if self._track_buttons_function == FN_SCENE: + if self._track_buttons_function == FN_PHRASE: for i in range(8): - scene = i + (8 if self._chains_bank == 1 else 0) - state = scene == (self._zynseq.bank - 1) + phrase = i + (8 if self._chains_scene == 1 else 0) + state = phrase == (self._zynseq.scene - 1) self._leds.led_state(BTN_TRACK_1 + i, state) return @@ -511,7 +511,7 @@ def refresh(self): FN_SELECT: lambda c: c.chain_id == self._active_chain, }[self._track_buttons_function] for i in range(8): - pos = i + (8 if self._chains_bank == 1 else 0) + pos = i + (8 if self._chains_scene == 1 else 0) chain = self._chain_manager.get_chain_by_position(pos) if not chain: break @@ -539,13 +539,13 @@ def note_on(self, note, velocity, shifted_override=None): elif note == BTN_SOFT_KEY_SOLO: self._track_buttons_function = FN_SOLO elif note == BTN_SOFT_KEY_REC_ARM: - self._track_buttons_function = FN_SCENE + self._track_buttons_function = FN_PHRASE elif note == BTN_SOFT_KEY_CLIP_STOP: self._track_buttons_function = FN_SEQUENCE_MANAGER elif note == BTN_LEFT: - self._chains_bank = 0 + self._chains_scene = 0 elif note == BTN_RIGHT: - self._chains_bank = 1 + self._chains_scene = 1 elif note == BTN_STOP_ALL_CLIPS: self._stop_all_sounds() elif note == BTN_PLAY: @@ -588,7 +588,7 @@ def update_strip(self, chan, symbol, value): if self._chain_manager.get_chain_id_by_index(pos) == chain_id: break - pos -= self._chains_bank * 8 + pos -= self._chains_scene * 8 if 0 > pos > 8: return self._leds.led_state(BTN_TRACK_1 + pos, value) @@ -598,7 +598,7 @@ def set_active_chain(self, chain, refresh): # Do not change chain if 'main' is selected if chain == 0: return - self._chains_bank = 0 if chain <= 8 else 1 + self._chains_scene = 0 if chain <= 8 else 1 self._active_chain = chain if refresh: self.refresh() @@ -616,7 +616,7 @@ def _update_control(self, type, ccnum, ccval, minv, maxv): return False mixer_chan = 255 else: - index = (ccnum - KNOB_1) + self._chains_bank * 8 + index = (ccnum - KNOB_1) + self._chains_scene * 8 chain = self._chain_manager.get_chain_by_index(index) if chain is None or chain.chain_id == 0: return False @@ -639,11 +639,11 @@ def _update_control(self, type, ccnum, ccval, minv, maxv): return True def _run_track_button_function(self, note): - index = (note - BTN_TRACK_1) + self._chains_bank * 8 + index = (note - BTN_TRACK_1) + self._chains_scene * 8 # FIXME: move this to padmatrix handler! - if self._track_buttons_function == FN_SCENE: - self._zynseq.select_bank(index + 1) + if self._track_buttons_function == FN_PHRASE: + self._zynseq.select_scene(index + 1) self._state_manager.send_cuia("SCREEN_ZYNPAD") return True @@ -745,32 +745,31 @@ def on_toggle_play_row(self, row): # If seqman is enabled, ignore row functions if self._seqman_func is not None: return False - if row >= self._zynseq.col_in_bank: + if row >= self._zynseq.LAUNCHER_COLS: return True # Get overall status: playing if at least one sequence is playing is_playing = False - for col in range(self._zynseq.col_in_bank): - seq = col * self._zynseq.col_in_bank + row + for col in range(self._zynseq.LAUNCHER_COLS): + seq = col * self._zynseq.LAUNCHER_COLS + row if seq in self._playing_seqs: is_playing = True break stop_states = (zynseq.SEQ_STOPPED, zynseq.SEQ_STOPPING, - zynseq.SEQ_STOPPINGSYNC) - play_states = (zynseq.SEQ_RESTARTING, - zynseq.SEQ_STARTING, zynseq.SEQ_PLAYING) - for col in range(self._zynseq.col_in_bank): - seq = col * self._zynseq.col_in_bank + row + zynseq.SEQ_STOPPING_SYNC) + play_states = (zynseq.SEQ_STARTING, zynseq.SEQ_PLAYING) + for col in range(self._zynseq.LAUNCHER_COLS): + seq = col * self._zynseq.LAUNCHER_COLS + row # We only play sequences that are not empty - if not is_playing and self._libseq.isEmpty(self._zynseq.bank, seq): + if not is_playing and self._libseq.isEmpty(self._zynseq.scene, seq): continue - state = self._libseq.getPlayState(self._zynseq.bank, seq) + state = self._libseq.getPlayState(self._zynseq.scene, seq) if is_playing and state in stop_states: continue if not is_playing and state in play_states: continue - self._libseq.togglePlayState(self._zynseq.bank, seq) + self._libseq.togglePlayState(self._zynseq.scene, seq) def on_track_changed(self, track, state): self._track_btn_pressed = track if state else None @@ -780,9 +779,9 @@ def on_track_changed(self, track, state): btn = BTN_TRACK_1 + track if btn == BTN_LEFT: - return self._change_scene(-1) + return self._change_phrase(-1) if btn == BTN_RIGHT: - return self._change_scene(1) + return self._change_phrase(1) func = { BTN_KNOB_CTRL_VOLUME: FN_COPY_SEQUENCE, @@ -795,9 +794,9 @@ def on_track_changed(self, track, state): # Function CLEAR does not have source sequence, remove it if func == FN_CLEAR_SEQUENCE and self._seqman_src_seq is not None: - scene, seq = self._seqman_src_seq + phrase, seq = self._seqman_src_seq self._seqman_src_seq = None - if scene == self._zynseq.bank: + if phrase == self._zynseq.scene: self._update_pad(seq) def on_shift_changed(self, state): @@ -823,11 +822,11 @@ def refresh(self): for c in range(self._cols): for r in range(self._rows): # Pad outside grid, switch off - if c >= self._zynseq.col_in_bank or r >= self._zynseq.col_in_bank: + if c >= self._zynseq.LAUNCHER_COLS or r >= self._zynseq.LAUNCHER_COLS: self.pad_off(c, r) continue - seq = c * self._zynseq.col_in_bank + r + seq = c * self._zynseq.LAUNCHER_COLS + r self._update_pad(seq, False) self._refresh_tool_buttons() @@ -851,13 +850,13 @@ def pad_press(self, pad): if self._seqman_func is not None: self._seqman_handle_pad_press(seq) elif self._track_btn_pressed is not None: - self._clear_sequence(self._zynseq.bank, seq) + self._clear_sequence(self._zynseq.scene, seq) elif self._is_record_pressed: self._start_pattern_record(seq) elif self._recording_seq == seq: self._stop_pattern_record() else: - self._libseq.togglePlayState(self._zynseq.bank, seq) + self._libseq.togglePlayState(self._zynseq.scene, seq) return True @@ -865,39 +864,42 @@ def pad_off(self, col, row): index = col * self._rows + row self._leds.led_off(self._pads[index]) - def update_seq_state(self, bank, seq, state=None, mode=None, group=None, refresh=True): - col, row = self._zynseq.get_xy_from_pad(seq) - idx = col * self._rows + row + def update_seq_state(self, phrase, chan, state=None, mode=None, refresh=True): + try: + col = self._chain_manager.get_pos_by_midi_chan(chan)[0] + except: + return + idx = col * self._rows + phrase if idx >= len(self._pads): return btn = self._pads[idx] is_empty = all( self._zynseq.is_pattern_empty(pattern) - for pattern in self._get_sequence_patterns(bank, seq)) - color = self.GROUP_COLORS[group] + for pattern in self._get_sequence_patterns(phrase, chan)) + color = self.GROUP_COLORS[chan] # If seqman is enabled, update according to it's function if self._seqman_func is not None: led_mode = self.BRIGHT_OFF if is_empty else LED_BRIGHT_100 if (self._seqman_func in (FN_COPY_SEQUENCE, FN_MOVE_SEQUENCE) and self._seqman_src_seq is not None): - src_scene, src_seq = self._seqman_src_seq - if src_scene == self._zynseq.bank and src_seq == seq: + src_phrase, src_seq = self._seqman_src_seq + if src_phrase == self._zynseq.scene and src_seq == chan: led_mode = LED_BLINKING_24 # Otherwise, update according to sequence state else: - if self._recording_seq == seq: + if self._recording_seq == chan: led_mode = LED_BLINKING_16 elif state == zynseq.SEQ_PLAYING: led_mode = LED_BLINKING_8 - self._playing_seqs.add(seq) + self._playing_seqs.add(chan) elif state in (zynseq.SEQ_STOPPING, zynseq.SEQ_STARTING): led_mode = LED_PULSING_2 else: led_mode = self.BRIGHT_OFF if is_empty else LED_BRIGHT_100 - self._playing_seqs.discard(seq) + self._playing_seqs.discard(chan) self._leds.led_on(btn, color, led_mode) @@ -910,17 +912,17 @@ def get_sequence_from_pad(self, pad): row = index % self._rows # Pad outside grid, discarded - if col >= self._zynseq.col_in_bank or row >= self._zynseq.col_in_bank: + if col >= self._zynseq.LAUNCHER_COLS or row >= self._zynseq.LAUNCHER_COLS: return None - return col * self._zynseq.col_in_bank + row + return col * self._zynseq.LAUNCHER_COLS + row def _handle_timed_button(self, btn, ptype): if btn == BTN_STOP_ALL_CLIPS: if ptype == CONST.PT_LONG: self._stop_all_sounds() else: - in_all_banks = ptype == CONST.PT_BOLD - self._stop_all_seqs(in_all_banks) + in_all_scenes = ptype == CONST.PT_BOLD + self._stop_all_seqs(in_all_scenes) def _seqman_handle_pad_press(self, seq): if self._seqman_func is None: @@ -930,46 +932,49 @@ def _seqman_handle_pad_press(self, seq): # FIXME: if Zynpad is open, also update it! # You can use self._current_screen... self._libseq.updateSequenceInfo() - seq_is_empty = self._libseq.isEmpty(self._zynseq.bank, seq) + seq_is_empty = self._libseq.isEmpty(self._zynseq.scene, seq) if self._seqman_func == FN_CLEAR_SEQUENCE: if not seq_is_empty: - self._clear_sequence(self._zynseq.bank, seq) + self._clear_sequence(self._zynseq.scene, seq) return # Set selected sequence as source if self._seqman_src_seq is None: if not seq_is_empty: - self._seqman_src_seq = (self._zynseq.bank, seq) + self._seqman_src_seq = (self._zynseq.scene, seq) else: # Clear source sequence - if self._seqman_src_seq == (self._zynseq.bank, seq): + if self._seqman_src_seq == (self._zynseq.scene, seq): self._seqman_src_seq = None # Copy/Move source to selected sequence (will be overwritten) else: if self._seqman_func == FN_COPY_SEQUENCE: self._copy_sequence( - *self._seqman_src_seq, self._zynseq.bank, seq) + *self._seqman_src_seq, self._zynseq.scene, seq) elif self._seqman_func == FN_MOVE_SEQUENCE: self._copy_sequence( - *self._seqman_src_seq, self._zynseq.bank, seq) + *self._seqman_src_seq, self._zynseq.scene, seq) self._clear_sequence(*self._seqman_src_seq) self._seqman_src_seq = None self._update_pad(seq) - def _change_scene(self, offset): - scene = min(64, max(1, self._zynseq.bank + offset)) - if scene != self._zynseq.bank: - self._zynseq.select_bank(scene) + def _change_phrase(self, offset): + phrase = min(64, max(1, self._zynseq.scene + offset)) + if phrase != self._zynseq.scene: + self._zynseq.select_scene(phrase) self._state_manager.send_cuia("SCREEN_ZYNPAD") def _update_pad(self, seq, refresh=True): - state = self._libseq.getSequenceState(self._zynseq.bank, seq) + phrase = int(seq / 17) + chan = seq % 17 + if chan > 15: + chan = 0xff + state = self._libseq.getSequenceState(self._zynseq.scene, phrase, seq) mode = (state >> 8) & 0xFF - group = (state >> 16) & 0xFF state &= 0xFF self.update_seq_state( - bank=self._zynseq.bank, seq=seq, state=state, mode=mode, group=group, + phrase=phrase, chan=chan, state=state, mode=mode, refresh=refresh) def _refresh_tool_buttons(self): @@ -987,14 +992,14 @@ def _refresh_tool_buttons(self): # If seqman is disabled, show playing status in row launchers playing_rows = { - seq % self._zynseq.col_in_bank for seq in self._playing_seqs} + seq % self._zynseq.LAUNCHER_COLS for seq in self._playing_seqs} for row in range(5): state = row in playing_rows self._leds.led_state(BTN_SOFT_KEY_START + row, state) def _start_pattern_record(self, seq): # Set pad's chain as active - channel = self._libseq.getChannel(self._zynseq.bank, seq, 0) + channel = self._libseq.getChannel(self._zynseq.scene, seq, 0) chain_id = self._chain_manager.get_chain_id_by_mixer_chan(channel) if chain_id is None: return @@ -1006,7 +1011,7 @@ def _start_pattern_record(self, seq): self._show_pattern_editor(seq, skip_arranger=True) # Start playing & recording - if self._libseq.getPlayState(self._zynseq.bank, seq) == zynseq.SEQ_STOPPED: + if self._libseq.getPlayState(self._zynseq.scene, seq) == zynseq.SEQ_STOPPED: self._state_manager.send_cuia("TOGGLE_PLAY") if not self._libseq.isMidiRecord(): self._state_manager.send_cuia("TOGGLE_RECORD") @@ -1014,18 +1019,18 @@ def _start_pattern_record(self, seq): self._recording_seq = seq self._update_pad(seq) - def _stop_all_seqs(self, in_all_banks=False): - bank = 0 if in_all_banks else self._zynseq.bank + def _stop_all_seqs(self, in_all_scenes=False): + scene = 0 if in_all_scenes else self._zynseq.scene while True: - seq_num = self._libseq.getSequencesInBank(bank) + seq_num = self._libseq.getSequencesInscene(scene) for seq in range(seq_num): - state = self._libseq.getPlayState(bank, seq) - if state not in [zynseq.SEQ_STOPPED, zynseq.SEQ_STOPPING, zynseq.SEQ_STOPPINGSYNC]: - self._libseq.togglePlayState(bank, seq) - if not in_all_banks: + state = self._libseq.getPlayState(scene, seq) + if state not in [zynseq.SEQ_STOPPED, zynseq.SEQ_STOPPING, zynseq.SEQ_STOPPING_SYNC]: + self._libseq.togglePlayState(scene, seq) + if not in_all_scenes: break - bank += 1 - if bank >= 64 or self._libseq.getPlayingSequences() == 0: + scene += 1 + if scene >= 64 or self._zynseq.playing_sequences == 0: break def _stop_pattern_record(self): @@ -1034,20 +1039,20 @@ def _stop_pattern_record(self): self._recording_seq = None self.refresh() - def _clear_sequence(self, scene, seq, create_empty=True): + def _clear_sequence(self, phrase, seq, create_empty=True): # Remove all patterns in all tracks - seq_len = self._libseq.getSequenceLength(scene, seq) + seq_len = self._libseq.getSequenceLength(phrase, seq) if seq_len != 0: - n_tracks = self._libseq.getTracksInSequence(scene, seq) + n_tracks = self._libseq.getTracksInSequence(self._zynseq.scene, phrase, seq) for track in range(n_tracks): - n_patts = self._libseq.getPatternsInTrack(scene, seq, track) + n_patts = self._libseq.getPatternsInTrack(self._zynseq.scene, phrase, seq, track) if n_patts == 0: continue pos = 0 while pos < seq_len: - pattern = self._libseq.getPatternAt(scene, seq, track, pos) + pattern = self._libseq.getPatternAt(self._zynseq.scene, phrase, seq, track, pos) if pattern != -1: - self._libseq.removePattern(scene, seq, track, pos) + self._libseq.removePattern(self._zynseq.scene, phrase, seq, track, pos) pos += self._libseq.getPatternLength(pattern) else: # Arranger's offset step is a quarter note (24 clocks) @@ -1055,40 +1060,40 @@ def _clear_sequence(self, scene, seq, create_empty=True): if n_tracks > 0: for track in range(n_tracks-1): - self._libseq.removeTrackFromSequence(scene, seq, track) + self._libseq.removeTrackFromSequence(phrase, seq, track) # Add a new empty pattern at the beginning of first track if create_empty: pattern = self._libseq.createPattern() - self._libseq.addPattern(scene, seq, 0, 0, pattern) + self._libseq.addPattern(self._zynseq.scene, phrase, seq, 0, 0, pattern) self._libseq.selectPattern(pattern) if self._pattern_template is not None: self._action_apply_pattern_template(pattern) - def _copy_sequence(self, src_scene, src_seq, dst_scene, dst_seq): - self._clear_sequence(dst_scene, dst_seq, create_empty=False) + def _copy_sequence(self, src_phrase, src_seq, dst_phrase, dst_seq): + self._clear_sequence(dst_phrase, dst_seq, create_empty=False) # Copy all patterns in all tracks - seq_len = self._libseq.getSequenceLength(src_scene, src_seq) + seq_len = self._libseq.getSequenceLength(src_phrase, src_seq) if seq_len != 0: - n_tracks = self._libseq.getTracksInSequence(src_scene, src_seq) + n_tracks = self._libseq.getTracksInSequence(src_phrase, src_seq) for track in range(n_tracks): - if track >= self._libseq.getTracksInSequence(dst_scene, dst_seq): - self._libseq.addTrackToSequence(dst_scene, dst_seq) + if track >= self._libseq.getTracksInSequence(dst_phrase, dst_seq): + self._libseq.addTrackToSequence(dst_phrase, dst_seq) n_patts = self._libseq.getPatternsInTrack( - src_scene, src_seq, track) + self._zynseq.scene, src_phrase, src_seq, track) if n_patts == 0: continue pos = 0 while pos < seq_len: pattern = self._libseq.getPatternAt( - src_scene, src_seq, track, pos) + src_phrase, src_seq, track, pos) if pattern != -1: new_pattern = self._libseq.createPattern() self._libseq.copyPattern(pattern, new_pattern) self._libseq.addPattern( - dst_scene, dst_seq, track, pos, new_pattern) + self._zynseq.scene, dst_phrase, dst_seq, track, pos, new_pattern) pos += self._libseq.getPatternLength(pattern) else: # Arranger's offset step is a quarter note (24 clocks) @@ -1096,7 +1101,7 @@ def _copy_sequence(self, src_scene, src_seq, dst_scene, dst_seq): # Also copy StepSeq instrument pages self._request_action("stepseq", "sync-sequences", - src_scene, src_seq, dst_scene, dst_seq) + src_phrase, src_seq, dst_phrase, dst_seq) def _action_set_pattern_template(self, pattern): self._pattern_template = pattern @@ -1246,7 +1251,7 @@ def __setattr__(self, name, value): # -------------------------------------------------------------------------- # Class to marshall/un-marshall saved state of StepSeq -# FIXME: add support for scenes too! +# FIXME: add support for phrases too! # -------------------------------------------------------------------------- class StepSeqState: def __init__(self): @@ -1486,7 +1491,7 @@ def __init__(self, state_manager, leds: FeedbackLEDs, dev_idx): # We need to receive clock though MIDI # FIXME: Changing clock source from user preference seems wrong! - self._state_manager.set_transport_clock_source(1) + #TODO: Clock source is now chosen with lib_zyncore.zmip_set_flag_system_rt # Pads ordered for cursor sliding + note pads self._pads = [] @@ -1598,16 +1603,16 @@ def refresh(self, shifted_override=None, only_steps=False): pad) if args is None else self._leds.led_on(pad, *args) def set_sequence(self, seq): - self._libseq.setSequence(seq) + self._libseq.selectSequence(seq) self._selected_seq = seq self._sequence_patterns = self._get_sequence_patterns( - self._zynseq.bank, seq, create=True) + self._zynseq.scene, seq, create=True) self._selected_pattern_idx = 0 self._pattern_clock_offset = 0 self._set_pattern(self._sequence_patterns[0]) # Update active chain and instruments page - chain_id = self._get_chain_id_by_sequence(self._zynseq.bank, seq) + chain_id = self._get_chain_id_by_sequence(self._zynseq.scene, seq) self._chain_manager.set_active_chain_by_id(chain_id) self._update_instruments(seq, chain_id) @@ -1659,10 +1664,10 @@ def note_on(self, note, velocity, shifted_override=None): elif note == BTN_PLAY: self._libseq.togglePlayState( - self._zynseq.bank, self._selected_seq) + self._zynseq.scene, self._selected_seq) state = self._libseq.getPlayState( - self._zynseq.bank, self._selected_seq) - if state in (zynseq.SEQ_STARTING, zynseq.SEQ_PLAYING, zynseq.SEQ_RESTARTING): + self._zynseq.scene, self._selected_seq) + if state in (zynseq.SEQ_STARTING, zynseq.SEQ_PLAYING): self._is_stage_play = True self.refresh() @@ -1696,7 +1701,7 @@ def note_on(self, note, velocity, shifted_override=None): return True if note == BTN_PLAY: - self._libseq.togglePlayState(self._zynseq.bank, self._selected_seq) + self._libseq.togglePlayState(self._zynseq.scene, self._selected_seq) elif BTN_PAD_START <= note <= BTN_PAD_END: self._pressed_pads[note] = time.time() @@ -1842,9 +1847,9 @@ def cc_change(self, ccnum, ccval): # Update sequence's chain volume elif ccnum == KNOB_2: self._show_screen_briefly( - screen="audio_mixer", cuia="SCREEN_AUDIO_MIXER", timeout=1500) + screen="mixer", cuia="SCREEN_MIXER", timeout=1500) chain_id = self._get_chain_id_by_sequence( - self._zynseq.bank, self._selected_seq) + self._zynseq.scene, self._selected_seq) chain = self._chain_manager.chains.get(chain_id) if chain is not None: mixer_chan = chain.mixer_chan @@ -1852,7 +1857,7 @@ def cc_change(self, ccnum, ccval): 0, min(100, self._zynmixer.get_level(mixer_chan) * 100 + delta)) self._zynmixer.set_level(mixer_chan, level / 100) - def update_seq_state(self, bank, seq, state=None, mode=None, group=None): + def update_seq_state(self, phrase, chan, state=None, mode=None): self._is_playing = state != zynseq.SEQ_STOPPED if state == zynseq.SEQ_STOPPED and self._cursor < self._used_pads: self._leds.remove_overlay(self._pads[self._cursor]) @@ -1890,7 +1895,7 @@ def _update_step_duration(self, step, delta): return note = self._selected_note.note - max_duration = self._libseq.getSteps() + max_duration = self._libseq.getSteps(self._selected_pattern) duration = self._libseq.getNoteDuration(step, note) + delta * 0.1 duration = round(min(max_duration, max(0.1, duration)), 1) self._set_note_duration(step, note, duration) @@ -1929,7 +1934,7 @@ def _update_step_stutter_duration(self, step, delta): self._play_step(step) def _update_note_pad_duration(self, pad, note_spec, delta): - max_duration = self._libseq.getSteps() + max_duration = self._libseq.getSteps(self._selected_pattern) note_spec.duration = \ round(min(max_duration, max(0.1, note_spec.duration + delta * 0.1)), 1) self._play_note_pad(pad) @@ -2035,14 +2040,14 @@ def _update_instruments(self, seq, chain_id): self._selected_note = self._note_pads.get(index) # This will be called as an action (look for 'sync-sequences' requests) - def _action_sync_sequences(self, src_bank, src_seq, dst_bank, dst_seq): - src_chain = self._get_chain_id_by_sequence(src_bank, src_seq) - dst_chain = self._get_chain_id_by_sequence(dst_bank, dst_seq) + def _action_sync_sequences(self, src_scene, src_seq, dst_scene, dst_seq): + src_chain = self._get_chain_id_by_sequence(src_scene, src_seq) + dst_chain = self._get_chain_id_by_sequence(dst_scene, dst_seq) src = self._saved_state.get_chain_by_id(src_chain) dst = self._saved_state.get_chain_by_id(dst_chain) dst["pages"] = deepcopy(src["pages"]) - # FIXME: add support for scenes + # FIXME: add support for phrases src_page = self._saved_state.get_page_by_sequence(src_seq) self._saved_state.set_sequence_selection(dst_seq, *src_page) @@ -2059,7 +2064,7 @@ def _change_instrument(self, pad): def _play_note_pad(self, pad, velocity=None, on=True, force=False): if not force: state = self._libseq.getPlayState( - self._zynseq.bank, self._selected_seq) + self._zynseq.scene, self._selected_seq) if state != zynseq.SEQ_STOPPED: return @@ -2071,7 +2076,7 @@ def _play_note_pad(self, pad, velocity=None, on=True, force=False): velocity = note_spec.velocity channel = self._libseq.getChannel( - self._zynseq.bank, self._selected_seq, 0) + self._zynseq.scene, self._selected_seq, 0) if on: self._note_player.play( note_spec.note, velocity, 0, channel, @@ -2082,7 +2087,7 @@ def _play_note_pad(self, pad, velocity=None, on=True, force=False): def _play_step(self, step, only_when_stopped=True): if only_when_stopped: state = self._libseq.getPlayState( - self._zynseq.bank, self._selected_seq) + self._zynseq.scene, self._selected_seq) if state != zynseq.SEQ_STOPPED: return @@ -2090,7 +2095,7 @@ def _play_step(self, step, only_when_stopped=True): velocity = self._libseq.getNoteVelocity(step, note) duration = self._libseq.getNoteDuration(step, note) channel = self._libseq.getChannel( - self._zynseq.bank, self._selected_seq, 0) + self._zynseq.scene, self._selected_seq, 0) stutt_count = self._libseq.getStutterCount(step, note) stutt_duration = self._libseq.getStutterDur(step, note) self._note_player.play( @@ -2111,7 +2116,7 @@ def _toggle_step(self, step): else: self._libseq.removeNote(step, spec.note) channel = self._libseq.getChannel( - self._zynseq.bank, self._selected_seq, 0) + self._zynseq.scene, self._selected_seq, 0) self._libseq.playNote(spec.note, 0, channel, 0) self.refresh(only_steps=True) @@ -2132,7 +2137,7 @@ def _on_next_step(self, ev): # Avoid turning on the first LED when is stopping state = self._libseq.getPlayState( - self._zynseq.bank, self._selected_seq) + self._zynseq.scene, self._selected_seq) if self._cursor == 0 and state != zynseq.SEQ_PLAYING: return if self._cursor < self._used_pads: @@ -2166,15 +2171,15 @@ def _update_for_selected_pattern(self): spb = self._libseq.getStepsPerBeat() self._clock.set_steps_per_beat(spb) - steps = self._libseq.getSteps() + steps = self._libseq.getSteps(self._selected_pattern) self._used_pads = min(32, steps) self._cursor = self._get_pattern_playhead() def _get_pattern_playhead(self): # NOTE: libseq.getPatternPlayhead() does not work here! - cps = self._libseq.getClocksPerStep() + cps = self._libseq.getClocksPerStep(self._selected_pattern) playpos = self._libseq.getPlayPosition( - self._zynseq.bank, self._selected_seq) + self._zynseq.scene, self._selected_seq) playpos -= self._pattern_clock_offset # If playhead is in previous patterns, return a big number (which will be ignored) @@ -2207,7 +2212,7 @@ def _change_to_previous_pattern(self): self._show_patterns_bar() def _change_to_next_pattern(self): - bank = self._zynseq.bank + scene = self._zynseq.scene seq = self._selected_seq # FIXME: Add support for track selection track = 0 @@ -2219,7 +2224,7 @@ def _change_to_next_pattern(self): if not self._is_shifted: return pattern = self._libseq.createPattern() - if not self._add_pattern_to_end_of_track(bank, seq, track, pattern): + if not self._add_pattern_to_end_of_track(scene, seq, track, pattern): logging.error(" could not add a new pattern!") return self._sequence_patterns.append(pattern) @@ -2248,7 +2253,7 @@ def _clear_pattern(self, index): current = self._libseq.getPatternIndex() pattern = self._sequence_patterns[index] self._libseq.selectPattern(pattern) - self._libseq.clear() + self._libseq.clearPattern(pattern) self._libseq.updateSequenceInfo() if current != -1 and current != pattern: self._libseq.selectPattern(current) @@ -2257,7 +2262,7 @@ def _clear_pattern(self, index): self.refresh(only_steps=True) def _remove_pattern(self, index): - bank = self._zynseq.bank + scene = self._zynseq.scene seq = self._selected_seq # FIXME: Add support for track selection track = 0 @@ -2271,17 +2276,17 @@ def _remove_pattern(self, index): for offset, pattern in enumerate(self._sequence_patterns[index:]): prev_position = position position = self._get_pattern_position(index + offset) - self._libseq.removePattern(bank, seq, track, position) + self._libseq.removePattern(self._zynseq.scene, scene, seq, track, position) if offset > 0: self._libseq.addPattern( - bank, seq, track, prev_position, pattern) + self._zynseq.scene, scene, seq, track, prev_position, pattern) # If pattern to remove is to the left of selected, then update selected if index <= self._selected_pattern_idx: self._selected_pattern_idx = max(0, self._selected_pattern_idx - 1) self._sequence_patterns = self._get_sequence_patterns( - self._zynseq.bank, seq, create=True) + self._zynseq.scene, seq, create=True) self._change_to_pattern_index(0, self._selected_pattern_idx) new_position = self._get_pattern_position(self._selected_pattern_idx) self._update_ui_arranger(cell_selected=( @@ -2309,7 +2314,7 @@ def _get_step_colors(self): if self._selected_note is None: return retval - num_steps = min(32, self._libseq.getSteps()) + num_steps = min(32, self._libseq.getSteps(self._selected_pattern)) note = self._selected_note.note duration = None for step in range(num_steps): @@ -2379,7 +2384,7 @@ def _create_note_control(self, kind, note): notes[col] = step_prx channel = self._libseq.getChannel( - self._zynseq.bank, self._selected_seq, 0) + self._zynseq.scene, self._selected_seq, 0) self._note_config = Control(self, notes, channel, row_led=row_led) self.refresh() return True diff --git a/zyngine/ctrldev/zynthian_ctrldev_akai_midimix.py b/zyngine/ctrldev/zynthian_ctrldev_akai_midimix.py index 1ac219452..d4460a8ae 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_akai_midimix.py +++ b/zyngine/ctrldev/zynthian_ctrldev_akai_midimix.py @@ -40,8 +40,6 @@ class zynthian_ctrldev_akai_midimix(zynthian_ctrldev_zynmixer): dev_ids = ["MIDI Mix IN 1"] driver_description = "Full mixer integration" - rec_mode = 0 - bank_left_note = 25 bank_right_note = 26 solo_note = 27 @@ -57,104 +55,73 @@ class zynthian_ctrldev_akai_midimix(zynthian_ctrldev_zynmixer): # Function to initialise class def __init__(self, state_manager, idev_in, idev_out=None): + self.rec_mode = 0 self.midimix_bank = 0 super().__init__(state_manager, idev_in, idev_out) # Update LED status for a single strip - def update_mixer_strip(self, chan, symbol, value): - if self.idev_out is None: + def update_mixer_strip(self, chan, symbol, value, mixbus=False): + if self.idev_out is None or mixbus: # Nothing to update in main strip nor other mixbuses implementation return - chain_id = self.chain_manager.get_chain_id_by_mixer_chan(chan) - if chain_id: - col = self.chain_manager.get_chain_index(chain_id) - if self.midimix_bank: - col -= 8 + try: + col = self.chain_manager.get_pos_by_mixer_chan(chan, mixbus) - self.scroll_h if 0 <= col < 8: if symbol == "mute": - lib_zyncore.dev_send_note_on( - self.idev_out, 0, self.mute_notes[col], value) + lib_zyncore.dev_send_note_on(self.idev_out, 0, self.mute_notes[col], value) elif symbol == "solo": - lib_zyncore.dev_send_note_on( - self.idev_out, 0, self.solo_notes[col], value) + lib_zyncore.dev_send_note_on(self.idev_out, 0, self.solo_notes[col], value) elif symbol == "rec" and self.rec_mode: - lib_zyncore.dev_send_note_on( - self.idev_out, 0, self.rec_notes[col], value) + lib_zyncore.dev_send_note_on(self.idev_out, 0, self.rec_notes[col], value) + except: + pass # Update LED status for active chain - def update_mixer_active_chain(self, active_chain): - if self.rec_mode: + def on_active_chain(self, active_chain_id): + # Scroll modes are DISABLED for MIDI MIX, so no need to call the base class method + #super().on_active_chain(active_chain_id) + + if self.rec_mode or self.idev_out is None: return - if self.midimix_bank: - col0 = 8 - else: - col0 = 0 for i in range(0, 8): - chain_id = self.chain_manager.get_chain_id_by_index(col0 + i) - if chain_id and chain_id == active_chain: + chain_id = self.get_filtered_chain_id_by_index(self.scroll_h + i) + if chain_id and chain_id == active_chain_id: rec = 1 else: rec = 0 - lib_zyncore.dev_send_note_on( - self.idev_out, 0, self.rec_notes[i], rec) + lib_zyncore.dev_send_note_on(self.idev_out, 0, self.rec_notes[i], rec) # Update full LED status def refresh(self): + super().refresh() if self.idev_out is None: return # Bank selection LED if self.midimix_bank: - col0 = 8 - lib_zyncore.dev_send_note_on( - self.idev_out, 0, self.bank_left_note, 0) - lib_zyncore.dev_send_note_on( - self.idev_out, 0, self.bank_right_note, 1) + lib_zyncore.dev_send_note_on(self.idev_out, 0, self.bank_left_note, 0) + lib_zyncore.dev_send_note_on(self.idev_out, 0, self.bank_right_note, 1) else: - col0 = 0 - lib_zyncore.dev_send_note_on( - self.idev_out, 0, self.bank_left_note, 1) - lib_zyncore.dev_send_note_on( - self.idev_out, 0, self.bank_right_note, 0) + lib_zyncore.dev_send_note_on(self.idev_out, 0, self.bank_left_note, 1) + lib_zyncore.dev_send_note_on(self.idev_out, 0, self.bank_right_note, 0) # Strips Leds for i in range(0, 8): - chain = self.chain_manager.get_chain_by_index(col0 + i) - - if chain and chain.mixer_chan is not None: - mute = self.zynmixer.get_mute(chain.mixer_chan) - solo = self.zynmixer.get_solo(chain.mixer_chan) - else: - chain = None - mute = 0 - solo = 0 + pos = self.scroll_h + i + chain_id = self.get_filtered_chain_id_by_index(pos) + mute = self.get_mixer_param("mute", pos) + solo = self.get_mixer_param("solo", pos) if not self.rec_mode: - if chain and chain == self.chain_manager.get_active_chain(): + if chain_id and chain_id == self.chain_manager.get_active_chain().chain_id: rec = 1 else: rec = 0 else: - if chain and chain.mixer_chan is not None: - rec = self.state_manager.audio_recorder.is_armed( - chain.mixer_chan) - else: - rec = 0 + rec = self.get_mixer_param("record", pos) - lib_zyncore.dev_send_note_on( - self.idev_out, 0, self.mute_notes[i], mute) - lib_zyncore.dev_send_note_on( - self.idev_out, 0, self.solo_notes[i], solo) - lib_zyncore.dev_send_note_on( - self.idev_out, 0, self.rec_notes[i], rec) - - def get_mixer_chan_from_device_col(self, col): - if self.midimix_bank: - col += 8 - chain = self.chain_manager.get_chain_by_index(col) - if chain: - return chain.mixer_chan - else: - return None + lib_zyncore.dev_send_note_on(self.idev_out, 0, self.mute_notes[i], mute) + lib_zyncore.dev_send_note_on(self.idev_out, 0, self.solo_notes[i], solo) + lib_zyncore.dev_send_note_on(self.idev_out, 0, self.rec_notes[i], rec) def midi_event(self, ev): evtype = (ev[0] >> 4) & 0x0F @@ -164,86 +131,52 @@ def midi_event(self, ev): return True elif note == self.bank_left_note: self.midimix_bank = 0 + self.scroll_h = 0 self.refresh() return True elif note == self.bank_right_note: self.midimix_bank = 1 + self.scroll_h = 8 self.refresh() return True elif note in self.mute_notes: - mixer_chan = self.get_mixer_chan_from_device_col( - self.mute_notes.index(note)) - if mixer_chan is not None: - if self.zynmixer.get_mute(mixer_chan): - val = 0 - else: - val = 1 - self.zynmixer.set_mute(mixer_chan, val, True) - # Send LED feedback - if self.idev_out is not None: - lib_zyncore.dev_send_note_on( - self.idev_out, 0, note, val) - elif self.idev_out is not None: - # If not associated mixer channel, turn-off the led - lib_zyncore.dev_send_note_on(self.idev_out, 0, note, 0) + pos = self.scroll_h + self.mute_notes.index(note) + val = self.toggle_mixer_param("mute", pos) + # Send LED feedback + if self.idev_out is not None: + lib_zyncore.dev_send_note_on(self.idev_out, 0, note, val) return True elif note in self.solo_notes: - mixer_chan = self.get_mixer_chan_from_device_col( - self.solo_notes.index(note)) - if mixer_chan is not None: - if self.zynmixer.get_solo(mixer_chan): - val = 0 - else: - val = 1 - self.zynmixer.set_solo(mixer_chan, val, True) - # Send LED feedback - if self.idev_out is not None: - lib_zyncore.dev_send_note_on( - self.idev_out, 0, note, val) - elif self.idev_out is not None: - # If not associated mixer channel, turn-off the led - lib_zyncore.dev_send_note_on(self.idev_out, 0, note, 0) + pos = self.scroll_h + self.solo_notes.index(note) + val = self.toggle_mixer_param("solo", pos) + # Send LED feedback + if self.idev_out is not None: + lib_zyncore.dev_send_note_on(self.idev_out, 0, note, val) return True elif note in self.rec_notes: - col = self.rec_notes.index(note) + pos = self.scroll_h + self.rec_notes.index(note) if not self.rec_mode: - if self.midimix_bank: - col += 8 - self.chain_manager.set_active_chain_by_index(col) + self.chain_manager.set_active_chain_by_index(pos) self.refresh() else: - mixer_chan = self.get_mixer_chan_from_device_col(col) - if mixer_chan is not None: - self.state_manager.audio_recorder.toggle_arm( - mixer_chan) - # Send LED feedback - if self.idev_out is not None: - val = self.state_manager.audio_recorder.is_armed( - mixer_chan) - lib_zyncore.dev_send_note_on( - self.idev_out, 0, note, val) - elif self.idev_out is not None: - # If not associated mixer channel, turn-off the led - lib_zyncore.dev_send_note_on(self.idev_out, 0, note, 0) + val = self.toggle_mixer_param("rec", pos) + # Send LED feedback + if self.idev_out is not None: + lib_zyncore.dev_send_note_on(self.idev_out, 0, note, val) return True elif evtype == 0xB: ccnum = ev[1] & 0x7F ccval = ev[2] & 0x7F if ccnum == self.master_ccnum: - self.zynmixer.set_level(255, ccval / 127.0) + self.set_mixer_param("level", -1, ccval / 127.0) return True elif ccnum in self.faders_ccnum: - mixer_chan = self.get_mixer_chan_from_device_col( - self.faders_ccnum.index(ccnum)) - if mixer_chan is not None: - self.zynmixer.set_level(mixer_chan, ccval / 127.0, True) + pos = self.scroll_h + self.faders_ccnum.index(ccnum) + self.set_mixer_param("level", pos, ccval / 127.0) return True elif ccnum in self.knobs3_ccnum: - mixer_chan = self.get_mixer_chan_from_device_col( - self.knobs3_ccnum.index(ccnum)) - if mixer_chan is not None: - self.zynmixer.set_balance( - mixer_chan, 2.0 * ccval/127.0 - 1.0) + pos = self.scroll_h + self.knobs3_ccnum.index(ccnum) + self.set_mixer_param("balance", pos, ccval/64.0 - 1.0) return True # Light-Off all LEDs diff --git a/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3.py b/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3.py index cecb70a75..d68417439 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3.py +++ b/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3.py @@ -396,8 +396,8 @@ def midi_event(self, ev: bytes): self._change_handler(self._config_handler) elif program == PROG_OPEN_MIXER: self.state_manager.send_cuia( - "SCREEN_ALSA_MIXER" if self._current_screen == "audio_mixer" else - "SCREEN_AUDIO_MIXER" + "SCREEN_ALSA_MIXER" if self._current_screen == "mixer" else + "SCREEN_MIXER" ) elif program == PROG_OPEN_ZYNPAD: self.state_manager.send_cuia({ @@ -532,7 +532,7 @@ def cc_change(self, ccnum, ccval): # This will happend when FULL LEVEL is on (or with a very strong press) if ccval == 127: - if self._current_screen in ["audio_mixer", "zynpad"]: + if self._current_screen in ["mixer", "zynpad"]: self._pads_action = FN_SELECT return self._change_chain(ccnum, ccval) @@ -563,7 +563,7 @@ def cc_change(self, ccnum, ccval): elif self.CC_PAD_START_B <= ccnum <= self.CC_PAD_END_B: self._chains_bank = 1 - if self._current_screen in ["audio_mixer", "zynpad"]: + if self._current_screen in ["mixer", "zynpad"]: if ccnum in (self.CC_PAD_VOLUME_A, self.CC_PAD_VOLUME_B): self._knobs_function = FN_VOLUME elif ccnum in (self.CC_PAD_PAN_A, self.CC_PAD_PAN_B): @@ -575,7 +575,7 @@ def cc_change(self, ccnum, ccval): # Is a Knob rotation elif self.CC_KNOBS_START <= ccnum <= self.CC_KNOBS_END: - if self._current_screen in ["audio_mixer", "zynpad"]: + if self._current_screen in ["mixer", "zynpad"]: if self._knobs_function == FN_VOLUME: self._update_volume(ccnum, ccval) elif self._knobs_function == FN_PAN: @@ -654,7 +654,7 @@ def _update_chain(self, type, ccnum, ccval, minv=None, maxv=None): def set_value(c, v): return self._zynmixer.set_mute(c, v, True) elif type == "solo": value = ccval < 64 - def set_value(c, v): return self._zynmixer.set_solo(c, v, True) + def set_value(c, v): return chain.set_solo(v) elif type == "select": return self._chain_manager.set_active_chain_by_id(chain.chain_id) else: @@ -953,8 +953,9 @@ def cc_change(self, ccnum, ccval): if self.CC_PAD_START <= ccnum <= self.CC_PAD_END: if ccval == 127 and self._current_screen == "zynpad": pad = ccnum - self.CC_PAD_START - seq = self._zynseq.get_pad_from_xy(pad // 4, pad % 4) - self._libseq.togglePlayState(self._zynseq.bank, seq) + info = self._zynseq.get_launcher_info(pad // 4, pad % 4) + if info is not None: + self._libseq.togglePlayState(self._zynseq.scene, info['phrase'], info["sequence"]) return if ccnum in (self.CC_PAD_SHIFT_A, self.CC_PAD_SHIFT_B): diff --git a/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3_moder.py b/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3_moder.py index bab254d9f..60ec7190d 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3_moder.py +++ b/zyngine/ctrldev/zynthian_ctrldev_akai_mpk_mini_mk3_moder.py @@ -106,7 +106,7 @@ def midi_event(self, ev): except: pass logging.debug(f"MODE => {mode_key} ({self.mode_index.value})") - zynsigman.send_queued(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_MESSAGE, message=f"{self.get_driver_name()}: {mode_key}") + zynsigman.send_queued(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_MESSAGE, message=f"MPKmini moder: {mode_key}") return True # Pad CCs, Bank A & B: elif evtype == 0xB: @@ -119,8 +119,7 @@ def midi_event(self, ev): except: pass logging.debug(f"MODE => {mode_key} ({self.mode_index.value})") - zynsigman.send_queued(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_MESSAGE, message=f"{self.get_driver_name()}: {mode_key}") + zynsigman.send_queued(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_MESSAGE, message=f"MPKmini moder: {mode_key}") return True - # ------------------------------------------------------------------------------ diff --git a/zyngine/ctrldev/zynthian_ctrldev_arturia_keylab_61_mk2.py b/zyngine/ctrldev/zynthian_ctrldev_arturia_keylab_61_mk2.py new file mode 100644 index 000000000..089425a57 --- /dev/null +++ b/zyngine/ctrldev/zynthian_ctrldev_arturia_keylab_61_mk2.py @@ -0,0 +1,455 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian Control Device Driver +# +# Zynthian Control Device Driver for "Arturia Keylab 61 Mk2" +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import jack +import time +import signal +import logging +from bisect import bisect +from copy import deepcopy +import multiprocessing as mp +from functools import partial +from threading import Thread, RLock, Event + +from zyngine import zynthian_state_manager +from zynlibs.zynseq import zynseq +from zyncoder.zyncore import lib_zyncore +from zyngine.zynthian_signal_manager import zynsigman +from zyngine.zynthian_engine_audioplayer import zynthian_engine_audioplayer + +from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynmixer, zynthian_ctrldev_zynpad, SCROLL_MODE_GUI_SEL +from zyngine.ctrldev.zynthian_ctrldev_base_extended import RunTimer, KnobSpeedControl, ButtonTimer, CONST +from zyngine.ctrldev.zynthian_ctrldev_base_ui import ModeHandlerBase +from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_base + +from collections import namedtuple + +Button = namedtuple("Button", ["sysex", "note", "chan"], defaults=[0, 0, 0]) + +# https://github.com/bitwig/bitwig-extensions/blob/953f4be03da06dcbfa7efdd42a5e2236c9a3b77e/src/main/java/com/bitwig/extensions/controllers/arturia/keylab/mk2/ButtonId.java + +# Pads +PAD1 = Button(0x70, 36, 9) +# PAD2-PAD15 = Button(0x71-0x7E, 37-50, 9) +PAD16 = Button(0x7F, 51, 9) + +# Pads come in on the DAW channel nine notes 36 and up +# Pad colors go out on sysex index 0x70 and up +PAD_MIDI_OFFSET = 36 +PAD_SYSEX_OFFSET = 0x70 + +# Track controls +SOLO = Button(0x60, 0x08) +MUTE = Button(0x61, 0x10) +RECORD_ARM = Button(0x62, 0x00) +READ = Button(0x63, 0x38) +WRITE = Button(0x64, 0x39) + +TRACK_SOLO = 8 +TRACK_MUTE = 16 +TRACK_RECORD = 0 +TRACK_READ = 56 +TRACK_WRITE = 57 + +# Global controls +SAVE = Button(0x65, 0x4A) +PUNCH_IN = Button(0x66, 0x57) +PUNCH_OUT = Button(0x67, 0x58) +METRO = Button(0x68, 0x59) +UNDO = Button(0x69, 0x51) + +GLOBAL_SAVE = 74 +GLOBAL_IN = Button(0x66, 0x57) +GLOBAL_OUT = Button(0x67, 0x58) +GLOBAL_METRO = Button(0x68, 0x59) +GLOBAL_UNDO = 81 + +# Transport controls +REWIND = Button(0x6A, 0x5B) +FORWARD = Button(0x6B, 0x5C) +STOP = Button(0x6C, 0x5D) +PLAY_OR_PAUSE = Button(0x6D, 0x5E) +RECORD = Button(0x6E, 0x5F) +LOOP = Button(0x6F, 0x56) + +TRANSPORT_BACK = 91 +TRANSPORT_FORWARD = 92 +TRANSPORT_STOP = 93 +TRANSPORT_PLAY_PAUSE = 94 +TRANSPORT_RECORD = Button(0x6E, 0x5F) +TRANSPORT_LOOP = 86 + +# Preset controls +PRESET_PREVIOUS = Button(0x1A, 0x62) +PRESET_NEXT = Button(0x1B, 0x63) +WHEEL_CLICK = Button(0, 0x54) + +# Navigation controls +NEXT = Button(0x1F, 0x31) +PREVIOUS = Button(0x20, 0x30) +BANK = Button(0x21, 0x21) + +# Select controls +SELECT_1 = Button(0x22, 0x18) +SELECT_2 = Button(0x23, 0x19) +SELECT_3 = Button(0x24, 0x1A) +SELECT_4 = Button(0x25, 0x1B) +SELECT_5 = Button(0x26, 0x1C) +SELECT_6 = Button(0x27, 0x1D) +SELECT_7 = Button(0x28, 0x1E) +SELECT_8 = Button(0x29, 0x1F) +SELECT_MULTI = Button(0x2A, 0x33) + + + +def pad_seq_index_inversion(pad_or_seq_index): + """ + The pads on the arturia are row major and on the zynthian + they are column major. This function converts between the two + + :param pad_or_seq_index: Description + """ + return pad_or_seq_index % 4 * 4 + pad_or_seq_index // 4 + + +# -------------------------------------------------------------------------- +# 'Arturia Keylab 61 Mk2' device controller class +# -------------------------------------------------------------------------- +class zynthian_ctrldev_arturia_keylab_61_mk2(zynthian_ctrldev_zynpad, zynthian_ctrldev_zynmixer): + """ + The Arturia Keylab 61 Mk2 + - has 4x4 pad area + - pitch shift + - 61 piano keys + - track control, global control and transport control buttons + - and an 8 x mixing set with 8x(1 encoder, 1 fader and 1 toggle button) + 1 master. + + + Resources: + - https://downloads.arturia.com/products/keylab-49-mkII/manual/keylab-mk2_Manual_1_0_0_EN.pdf + - https://github.com/bitwig/bitwig-extensions/tree/953f4be03da06dcbfa7efdd42a5e2236c9a3b77e/src/main/java/com/bitwig/extensions/controllers/arturia/keylab/mk2 + - https://github.com/mhugo/sysex/blob/dc3c43de2e17565b6713414f42adb76efa71b702/README.md + """ + + dev_ids = ["KeyLab mkII 61 IN 2"] + driver_name = 'Arturia Keylab 61 Mk2' + driver_description = 'Full UI integration' + # Unroute 9: pads + unroute_from_chains = True # 0b0000001000000000 # allow most channels. + + # TODO: Are these colors any good? + PAD_COLORS = [ + (127, 0, 0), (0, 127, 0), (0, 0, 127), (127, 127, 0), + (127, 0, 127), (0, 127, 127), (127, 64, 0), (127, 0, 64), + (0, 127, 64), (64, 127, 0), (0, 64, 127), (64, 0, 127), + (127, 127, 127), (80, 80, 80), (40, 40, 40), (0, 0, 0) + ] + COLOR_PLAYING = (0, 127, 0) + COLOR_STARTING = (127, 127, 0) + COLOR_STOPPING = (127, 0, 0) + COLOR_EMPTY = (0, 0, 0) + + + + def __init__(self, state_manager: zynthian_state_manager, idev_in, idev_out=None): + logging.info("initializing {} with port in:{} out:{}".format(self.driver_name, idev_in, idev_out)) + + # Ideally these settings could be customized by user via GUI. + # No idea how to do that yet. + self.record_pressed = False + self._chain_manager = state_manager.chain_manager + + # NOTE: init will call refresh(), so _current_hanlder must be ready! + super().__init__(state_manager, idev_in, idev_out) + self.cols = 4 + self.rows = 4 + + def init(self): + super().init() + self._enter_daw_mode() + zynsigman.register_queued(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_METRO, self.update_metronome) + self._send_display_sysex("Zynthian", "Connected") + + def _enter_daw_mode(self): + """Enters DAW mode and sets the DAW preset to Live.""" + if self.idev_out is None: + return + + # Init DAW preset in Live mode (DAWMode.Live.getID() is 0x02) + msg1 = bytearray.fromhex("F0 00 20 6B 7F 42 02 00 40 52 02 F7") + lib_zyncore.dev_send_midi_event(self.idev_out, bytes(msg1), len(msg1)) + + # Set to DAW mode + msg2 = bytearray.fromhex("F0 00 20 6B 7F 42 05 02 F7") + lib_zyncore.dev_send_midi_event(self.idev_out, bytes(msg2), len(msg2)) + + def end(self): + super().end() + zynsigman.unregister(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_METRO, self.update_metronome) + #zynthian_ctrldev_zynpad.end(self) + + def refresh(self): + super().refresh() + + def update_metronome(self, mode, volume): + # zero is off and 5 is silent + self.setButtonState(GLOBAL_METRO.sysex, mode != 0 and mode != 5) + + def update_pad(self, row, col, pad_info): + dim_ratio = 32 + if self.idev_out is None: + return + + # Only handle first 4 rows and 4 columns (4x4 grid) + if row >= 4 or col >= 4: + return + + # Compute linear sequence index for pad mapping + seq = row * 4 + col + + # Map to Arturia pad number then to led_id (0x70-0x7F) + pad_number = seq # pad_seq_index_inversion(seq) + led_id = 0x70 + pad_number + + if pad_info is None: + r, g, b = self.COLOR_EMPTY + else: + state = pad_info.get("state") + group = pad_info.get("group", 0) + + if state == zynseq.SEQ_STOPPED: + r, g, b = self.PAD_COLORS[group % len(self.PAD_COLORS)] + # dim it + r, g, b = r // dim_ratio, g // dim_ratio, b // dim_ratio + elif state == zynseq.SEQ_PLAYING: + r, g, b = self.PAD_COLORS[group % len(self.PAD_COLORS)] + elif state == zynseq.SEQ_STOPPING: + r, g, b = self.COLOR_STOPPING + elif state == zynseq.SEQ_STARTING: + r, g, b = self.COLOR_STARTING + else: + r, g, b = self.COLOR_EMPTY + + self._send_led_sysex(led_id, r, g, b) + + + def midi_event(self, ev): + """ get a midi event and do something with it. """ + self._log_midi(ev, self.idev) + + if ev == b'\xf0\x00\x20\x6b\x7f\x42\x02\x00\x00\x15\x00\xf7': + # if we get the switch back into daw mode + self.refresh() + return True + + evtype = (ev[0] >> 4) & 0x0F + evchan = ev[0] & 0x0F # exists even if invalid for system messages + + # DAW controller MIDI device + if evtype == 0x9: + # note on + note = ev[1] & 0x7F + _vel = ev[2] & 0x7F + if note == TRANSPORT_PLAY_PAUSE: + # play/pause button + # todo should there be one transport toggle? Seems weird these are separate. + self.state_manager.send_cuia("TOGGLE_PLAY") + if note == TRANSPORT_STOP: + # play/pause button + # todo should there be one transport toggle? Seems weird these are separate. + self.state_manager.send_cuia("STOP") + if note == TRANSPORT_RECORD.note: + self.record_pressed = not self.record_pressed + self.setButtonState(TRANSPORT_RECORD.sysex, self.record_pressed) + self.state_manager.send_cuia("TOGGLE_RECORD") + if note == GLOBAL_METRO.note: + self.zynseq.zctrl_metro_mode.toggle() + self._send_display_sysex("Metronome", "mode: " + self.zynseq.zctrl_metro_mode.get_value2label()) + if note >= SELECT_1.note and note <= SELECT_8.note: + self._chain_manager.set_active_chain_by_id(note - SELECT_1.note + 1) + + if evchan == 9 and evtype == 0x9: + # note off + note = ev[1] & 0x7F + vel = ev[2] & 0x7F + if 36 <= note <= 51 and vel > 0: + # This is a pad press. + # Map Arturia note to zynpad seq, then to (phrase, midi_chan) + seq = pad_seq_index_inversion(note - PAD_MIDI_OFFSET) + phrase = seq % 4 + midi_chan = seq // 4 + logging.info(f"Pad press: note={note}, seq={seq}, phrase={phrase}, midi_chan={midi_chan}, scene={self.zynseq.scene}") + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, midi_chan) + return True + + return True + + def update_mixer_strip(self, chan, symbol, value, mixbus=None): + """Update hardware indicators for a mixer strip: mute, solo, level, balance, etc. + *SHOULD* be implemented by child class + + chan - Mixer strip index + symbol - Control name + value - Control value + """ + # we could probably update color based on if a chain is present/solo/mute etc + # keylab doesn't have level/balance indicatiors + pass + + + def on_active_chain(self, active_chain_id): + super().on_active_chain(active_chain_id) + self.update_mixer_active_chain(active_chain_id) + + def update_mixer_active_chain(self, active_chain): + """Update hardware indicators for active_chain + *SHOULD* be implemented by child class + + active_chain - Active chain + """ + chain_page = active_chain//8 + chain_index_in_page = active_chain % 8 - 1 + print(chain_index_in_page) + + for i in range(SELECT_1.sysex, SELECT_1.sysex + 8): + self._send_led_sysex(i, 0, 0, 0) + + self._send_led_sysex(SELECT_1.sysex + chain_index_in_page, 127, 0, 0) + + + def _log_midi(self, ev, idev): + if not ev: + return + + status = ev[0] + raw_hex = " ".join("{:02X}".format(b) for b in ev) + msg_desc = "" + + + if status >= 0xF0: + # System Messages + if status == 0xF0: msg_desc = "SysEx" + elif status == 0xF1: msg_desc = "MTC Quarter Frame" + elif status == 0xF2: + val = (ev[1] & 0x7F if len(ev) > 1 else 0) | ((ev[2] & 0x7F if len(ev) > 2 else 0) << 7) + msg_desc = "Song Position {}".format(val) + elif status == 0xF3: + msg_desc = "Song Select {}".format(ev[1] & 0x7F if len(ev) > 1 else 0) + elif status == 0xF6: msg_desc = "Tune Request" + elif status == 0xF7: msg_desc = "EOX" + elif status == 0xF8: msg_desc = "Clock" + elif status == 0xFA: msg_desc = "Start" + elif status == 0xFB: msg_desc = "Continue" + elif status == 0xFC: msg_desc = "Stop" + elif status == 0xFE: msg_desc = "Active Sensing" + elif status == 0xFF: msg_desc = "Reset" + else: msg_desc = "System Undefined" + elif status >= 0x80: + # Channel Messages + cmd = status & 0xF0 + chan = (status & 0x0F) + d1 = ev[1] & 0x7F if len(ev) > 1 else 0 + d2 = ev[2] & 0x7F if len(ev) > 2 else 0 + + if cmd == 0x80: msg_desc = "Note Off Ch={} Note={} Vel={}".format(chan, d1, d2) + elif cmd == 0x90: msg_desc = "Note {} Ch={} Note={} Vel={}".format("Off" if d2 == 0 else "On", chan, d1, d2) + elif cmd == 0xA0: msg_desc = "Poly Pressure Ch={} Note={} Val={}".format(chan, d1, d2) + elif cmd == 0xB0: msg_desc = "CC Ch={} Ctrl={} Val={}".format(chan, d1, d2) + elif cmd == 0xC0: msg_desc = "PC Ch={} Prog={}".format(chan, d1) + elif cmd == 0xD0: msg_desc = "Channel Pressure Ch={} Val={}".format(chan, d1) + elif cmd == 0xE0: msg_desc = "Pitch Bend Ch={} Val={}".format(chan, d1 | (d2 << 7)) + else: + msg_desc = "Unknown/Data" + + # self._send_display_sysex(msg_desc, raw_hex) + logging.info("MIDI: idev={} [{}] {}".format(idev, msg_desc, raw_hex)) + + def _send_led_sysex(self, led_id, r, g, b): + """Sends a SysEx message to the Arturia Keylab 61 Mk2 to control an LED.""" + if self.idev_out is None: + return + + # Start of the SysEx message + msg = bytearray.fromhex("F0 00 20 6B 7F 42 02 00 16") + + # Append LED ID and color values + msg.append(led_id) + msg.append(r) + msg.append(g) + msg.append(b) + + # End of SysEx + msg.append(0xF7) + + lib_zyncore.dev_send_midi_event(self.idev_out, bytes(msg), len(msg)) + + def _send_display_sysex(self, upper, lower): + """Sends a SysEx message to the Arturia Keylab 61 Mk2 to display text on its screen. + + The Keylab's display has two lines. This method sets the text for both. + + Args: + upper (str): The text to display on the upper line (max 16 chars). + lower (str): The text to display on the lower line (max 16 chars). + """ + if self.idev_out is None: + return + + # Start of the SysEx message + msg = bytearray.fromhex("F0 00 20 6B 7F 42 04 00 60 01") + + # Append upper string, padded to 16 bytes with space + upper_bytes = upper.encode('ascii', 'ignore') + upper_bytes = upper_bytes[:16].ljust(16, ' '.encode('ascii')) + msg.extend(upper_bytes) + + # Append static part + msg.extend(bytearray.fromhex("00 02")) + + # Append lower string, padded to 16 bytes with nulls + lower_bytes = lower.encode('ascii', 'ignore') + lower_bytes = lower_bytes[:16].ljust(16, b'\x00') + msg.extend(lower_bytes) + + msg.extend(bytearray.fromhex("00 F7")) + lib_zyncore.dev_send_midi_event(self.idev_out, bytes(msg), len(msg)) + + def setButtonState(self, sysex_id, is_on): + """Sends a SysEx message to set the state of a button LED.""" + if self.idev_out is None: + return + + intensity = 0x7f if is_on else 0x04 + msg = bytearray.fromhex("F0 00 20 6B 7F 42 02 00 10") + msg.append(sysex_id) + msg.append(intensity) + msg.append(0xF7) + + lib_zyncore.dev_send_midi_event(self.idev_out, bytes(msg), len(msg)) + + def pad_off(self, col, row): + seq = row * self.cols + col + if seq < 16: + pad_number = pad_seq_index_inversion(seq) + led_id = PAD_SYSEX_OFFSET + pad_number + self._send_led_sysex(led_id, 0, 0, 0) diff --git a/zyngine/ctrldev/zynthian_ctrldev_base.py b/zyngine/ctrldev/zynthian_ctrldev_base.py index a9221013a..ecb029932 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_base.py +++ b/zyngine/ctrldev/zynthian_ctrldev_base.py @@ -5,7 +5,7 @@ # # Zynthian Control Device Manager Class # -# Copyright (C) 2015-2024 Fernando Moyano +# Copyright (C) 2015-2025 Fernando Moyano # Brian Walton # Oscar Acena # @@ -36,11 +36,19 @@ import zynautoconnect from zyncoder.zyncore import lib_zyncore from zyngine.zynthian_signal_manager import zynsigman +from zynlibs.zynmixer.zynmixer import SS_ZYNMIXER_SET_VALUE +from zynlibs.zynseq import zynseq # ------------------------------------------------------------------------------------------------------------------ # Control device base class # ------------------------------------------------------------------------------------------------------------------ +SCROLL_MODE_DISABLED = 0 +SCROLL_MODE_FIXED = 1 +SCROLL_MODE_GUI_SEL = 2 +SCROLL_MODE_GUI_VIEW = 3 +SCROLL_MODE_CTRLDEV = 4 + class zynthian_ctrldev_base: @@ -62,53 +70,101 @@ class zynthian_ctrldev_base: @classmethod def get_autoload_flag(cls): + """Returns autoload flag value""" + return cls.autoload_flag - # Function to initialise class def __init__(self, state_manager, idev_in, idev_out=None): + """Class Constructor + + state_manager - state manager object + idev_in - integer + idev_out - integer + """ + self.state_manager = state_manager self.chain_manager = state_manager.chain_manager + self.zynseq = state_manager.zynseq + # Slot index where the input device is connected, starting from 1 (0 = None) self.idev = idev_in # Slot index where the output device (feedback), if any, is connected, starting from 1 (0 = None) self.idev_out = idev_out + # Filtered chain list + self.chain_ids_filtered = [] + self.chain_type_filter = [] # List of chain types to include (empty for all) => [midi, audio, synth, generator] # OPTIONAL: real-time MIDI processor (jack client), inserted between the input device and zmip self.midiproc_jackname = None self.midiproc = None + self.cols = 0 # Quantity of columns of controllers, usually mapped to chains + self.rows = 0 # Quantity of rows of controllers, usually mapped to phrases + self.scroll_h = 0 # Offset of first column / chain + self.scroll_v = 0 # Offset of first phrase / row of pads + self.scroll_mode = None + self.scroll_bank_mode = False # TODO: Implement ctrl scrolls by whole banks of cols/rows + self.set_scroll_mode(SCROLL_MODE_DISABLED) - # Returns the driver name @classmethod def get_driver_name(cls): + """Returns the driver name""" + if cls.driver_name is None: return cls.__name__[17:] else: return cls.driver_name - # Returns the driver description @classmethod def get_driver_description(cls): + """Returns the driver description""" + return cls.driver_description - # Send SysEx universal inquiry. - # It's answered by some devices with a SysEx message. def send_sysex_universal_inquiry(self): + """Send SysEx universal inquiry. + It's answered by some devices with a SysEx message.""" + if self.idev_out > 0: msg = bytes.fromhex("F0 7E 7F 06 01 F7") lib_zyncore.dev_send_midi_event(self.idev_out, msg, len(msg)) - # Initialize control device: setup, register signals, etc - # It *SHOULD* be implemented by child class def init(self): + """Initialize control device: setup, register signals, etc + It *SHOULD* be implemented by child class""" + self.init_midiproc() self.refresh() - # End control device: restore initial state, unregister signals, etc - # It *SHOULD* be implemented by child class + # Register for chain add/remove + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_ADD_CHAIN, self.refresh) + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_REMOVE_CHAIN, self.refresh) + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_REMOVE_ALL_CHAINS, self.refresh) + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_MOVE_CHAIN, self.refresh) + # Register for snapshot loading + zynsigman.register_queued(zynsigman.S_STATE_MAN, self.state_manager.SS_LOAD_SNAPSHOT, self.refresh) + # Register for GUI changes + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.on_active_chain) + zynsigman.register_queued(zynsigman.S_GUI, zynsigman.SS_GUI_VIEW_POS, self.on_gui_view_pos) + def end(self): + """End control device: restore initial state, unregister signals, etc + It *SHOULD* be implemented by child class""" + + # Unregister from snapshot loading + zynsigman.unregister(zynsigman.S_STATE_MAN, self.state_manager.SS_LOAD_SNAPSHOT, self.refresh) + # Unregister from processor tree changes + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_ADD_CHAIN, self.refresh) + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_REMOVE_CHAIN, self.refresh) + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_REMOVE_ALL_CHAINS, self.refresh) + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_MOVE_CHAIN, self.refresh) + # Unregister from GUI changes + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.on_active_chain) + zynsigman.unregister(zynsigman.S_GUI, zynsigman.SS_GUI_VIEW_POS, self.on_gui_view_pos) + self.end_midiproc() - # Spawn midiproc task using multiprocessing API def init_midiproc(self): + """Spawn midiproc task using multiprocessing API""" + midiproc_task = getattr(self, "midiproc_task", None) if callable(midiproc_task): try: @@ -122,8 +178,9 @@ def init_midiproc(self): logging.exception(traceback.format_exc()) #logging.error(e) - # Terminate middings process def end_midiproc(self): + """Terminate middings process""" + if self.midiproc: try: self.midiproc.terminate() @@ -134,104 +191,278 @@ def end_midiproc(self): except Exception as e: logging.error(e) - # The midiproc task itself. It runs in a spawned process. - # It must call self._midiproc_task() to reset signal handlers - # *COULD* be implemented by child class # def midiproc_task(self): + # """The midiproc task itself. It runs in a spawned process. + # It must call self._midiproc_task() to reset signal handlers + # *COULD* be implemented by child class""" + # # self.midiproc_task_reset_signal_handlers() # # Implementation goes here! - # Reset process signal handlers. - # It *MUST* be called from midiproc_task, running in a spawned process. @staticmethod def midiproc_task_reset_signal_handlers(): + """Reset process signal handlers. + It *MUST* be called from midiproc_task, running in a spawned process.""" + signal.signal(signal.SIGHUP, signal.SIG_DFL) signal.signal(signal.SIGINT, signal.SIG_DFL) signal.signal(signal.SIGQUIT, signal.SIG_DFL) signal.signal(signal.SIGTERM, signal.SIG_DFL) - # Refresh full device status (LED feedback, etc) - # *COULD* be implemented by child class + def get_num_filtered_chains(self): + return len(self.chain_ids_filtered) + + def get_filtered_chain_id_by_index(self, index): + """Get filtered chain ID by index + + index - Index in filtered chain list + return: integer + """ + + try: + chain_id = self.chain_ids_filtered[index] + if chain_id > 0: + return chain_id + except: + pass + return None + + def get_filtered_chain_by_index(self, index): + """Get filtered chain by index + + index - Index in filtered chain list + return: chain + """ + + try: + chain_id = self.chain_ids_filtered[index] + if chain_id > 0: + return self.chain_manager.chains[chain_id] + except: + pass + return None + + def get_filtered_index_by_chain(self, chain): + """Get index of chain in the filtered chain list + + chain - chain to find in filtered list + return: integer + """ + try: + return self.chain_ids_filtered.index(chain.chain_id) + except: + return -1 + + def get_filtered_index_by_chain_id(self, chain_id): + """Get index of chain in the filtered chain list + + chain - chain to find in filtered list + return: integer + """ + try: + return self.chain_ids_filtered.index(chain_id) + except: + return -1 + + def get_filtered_midi_chan_by_index(self, index): + """Get filtered chain MIDI channel by index""" + try: + chain_id = self.chain_ids_filtered[index] + if chain_id > 0: + return self.chain_manager.chains[chain_id].midi_chan + except: + pass + return None + def refresh(self): - pass - #logging.debug(f"Refresh LEDs for {type(self).__name__}: NOT IMPLEMENTED!") + """Refresh full device status (LED feedback, etc) + *COULD* be implemented by child class + """ + + self.chain_ids_filtered = self.chain_manager.get_chain_ids_filtered(self.chain_type_filter) + logging.debug(f"Filtered Chains {self.chain_type_filter}: {self.chain_ids_filtered}") - # Device MIDI event handler - # *COULD* be implemented by child class def midi_event(self, ev): + """Device MIDI event handler + *COULD* be implemented by child class + """ + return False #logging.debug(f"MIDI EVENT for '{type(self).__name__}'") - # Light-Off LEDs - # *COULD* be implemented by child class def light_off(self): + """Light-Off LEDs + *COULD* be implemented by child class + """ + pass #logging.debug(f"Lighting Off LEDs for {type(self).__name__}: NOT IMPLEMENTED!") - # Sleep On - # *COULD* be improved by child class def sleep_on(self): + """Sleep On + *COULD* be improved by child class + """ + self.light_off() - # Sleep Off - # *COULD* be improved by child class def sleep_off(self): + """Sleep Off + *COULD* be improved by child class + """ + self.refresh() - # Return driver's state dictionary - # *COULD* be implemented by child class def get_state(self): - return None + """Return driver's state dictionary + *COULD* be extended by child class""" + + return { + "scroll_mode": self.scroll_mode + } - # Restore driver's state - # *COULD* be implemented by child class def set_state(self, state): - pass + """Restore driver's state + *COULD* be extended by child class""" + + if "scroll_mode" in state: + self.set_scroll_mode(state["scroll_mode"]) + + def get_scroll_mode(self): + return self.scroll_mode + + def set_scroll_mode(self, mode): + """Set the chain and phrase scroll mode + mode - New scroll mode""" + if mode is None: + mode = SCROLL_MODE_DISABLED + if mode < SCROLL_MODE_DISABLED or mode > SCROLL_MODE_CTRLDEV: + return + + self.scroll_mode = mode + if self.scroll_mode == SCROLL_MODE_FIXED: + self.scroll_h = self.scroll_v = 0 + + def on_active_chain(self, active_chain_id): + """Handle active chain selection + *COULD* be implemented by child class""" + + if self.scroll_mode == SCROLL_MODE_GUI_SEL and active_chain_id != 0: + pos = self.chain_manager.get_chain_index(active_chain_id) + if pos < self.scroll_h: + self.scroll_h = pos + elif pos >= self.scroll_h + self.cols: + self.scroll_h = max(0, min(pos - self.cols + 1, len(self.chain_ids_filtered) - self.cols)) + else: + return + self.refresh() + + def on_gui_view_pos(self, left_chain=None, top_phrase=None): + """Update GUI scroll position + *COULD* be implemented by child class""" + + if self.scroll_mode == SCROLL_MODE_GUI_VIEW: + refresh = False + if self.scroll_h != left_chain: + self.scroll_h = left_chain + refresh = True + if self.scroll_v != top_phrase: + self.scroll_v = top_phrase + refresh = True + if refresh: + self.refresh() + return True # ------------------------------------------------------------------------------------------------------------------ # Zynpad control device base class # ------------------------------------------------------------------------------------------------------------------ + + class zynthian_ctrldev_zynpad(zynthian_ctrldev_base): dev_zynpad = True # Can act as a zynpad trigger device def __init__(self, state_manager, idev_in, idev_out=None): - self.cols = 8 - self.rows = 8 - self.zynseq = state_manager.zynseq super().__init__(state_manager, idev_in, idev_out) + self.cols = 8 # Quatity of columns of physical launcher buttons + self.rows = 8 # Quatity of rows of physical launcher buttons + self.phrase_launcher_col = self.cols # Index of column used as phrase launcher def init(self): super().init() # Register for zynseq updates - zynsigman.register_queued(zynsigman.S_STEPSEQ, self.zynseq.SS_SEQ_PLAY_STATE, self.update_seq_state) - zynsigman.register_queued(zynsigman.S_STEPSEQ, self.zynseq.SS_SEQ_REFRESH, self.refresh) + zynsigman.register_queued(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_PLAY_STATE, self.update_seq_state) + zynsigman.register_queued(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_STATE, self.refresh) + # Register phrase change + zynsigman.register_queued(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_SELECT_PHRASE, self.on_active_phrase) def end(self): # Unregister from zynseq updates - zynsigman.unregister(zynsigman.S_STEPSEQ, self.zynseq.SS_SEQ_PLAY_STATE, self.update_seq_state) - zynsigman.unregister(zynsigman.S_STEPSEQ,self.zynseq.SS_SEQ_REFRESH, self.refresh) + zynsigman.unregister(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_PLAY_STATE, self.update_seq_state) + zynsigman.unregister(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_STATE, self.refresh) + # Unregister phrase change + zynsigman.unregister(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_SELECT_PHRASE, self.on_active_phrase) + # Light off self.light_off() super().end() - def update_seq_bank(self): - """Update hardware indicators for active bank and refresh sequence state as needed. - *COULD* be implemented by child class + def update_seq_state(self, phrase, chan): + """Update hardware indicators for a sequence (pad): playing state etc. + *SHOULD* be implemented by child class + + phrase - phrase index (row) + chan - zynseq's midi chan """ - pass - def update_seq_state(self, bank, seq, state=None, mode=None, group=None): - """Update hardware indicators for a sequence (pad): playing state etc. + #logging.debug(f"UPDATE SEQ STATE {phrase}, {chan}") + if chan is None or self.idev_out is None: + return + + row = phrase - self.scroll_v + if row < 0 or row >= self.rows: + return + + # Phrase launcher + if chan == 32: + col = self.phrase_launcher_col + try: + pad_info = self.zynseq.state["scenes"][self.zynseq.scene]["phrases"][phrase] + pad_info["empty"] = False + except: + pad_info = None + self.update_pad(row, col, pad_info) + # Sequence/Clip launcher + else: + try: + pad_info = self.zynseq.state["scenes"][self.zynseq.scene]["phrases"][phrase]["sequences"][chan] + # Sequence + if pad_info["group"] < 16: + try: + pattern = pad_info["tracks"][0]["patns"]["0"] + pad_info["empty"] = len(self.zynseq.state["patns"][str(pattern)]["events"]) == 0 + except: + pad_info["empty"] = True + # Clippy + else: + # TODO Fix this! + pad_info["empty"] = False + except IndexError: + pad_info = None + for idx in self.chain_manager.get_pos_by_midi_chan(chan): + col = idx - self.scroll_h + if 0 <= col < self.cols: + self.update_pad(row, col, pad_info) + + def update_pad(self, row, col, pad_info): + """Update the pad at row,col *SHOULD* be implemented by child class - bank - bank - seq - sequence index - state - sequence's state - mode - sequence's mode - group - sequence's group + row - row + col - column + chan - zynseq's midi chan + pad_info - dictionary with the pad info """ - logging.debug(f"Update sequence playing state for {type(self).__name__}: NOT IMPLEMENTED!") + pass def pad_off(self, col, row): """Light-Off the pad specified with column & row @@ -243,70 +474,143 @@ def refresh(self): """Refresh full device status (LED feedback, etc) *COULD* be implemented by child class """ + super().refresh() if self.idev_out is None: return - self.update_seq_bank() - for i in range(self.cols): - for j in range(self.rows): - if i >= self.zynseq.col_in_bank or j >= self.zynseq.col_in_bank: - self.pad_off(i, j) - else: - seq = i * self.zynseq.col_in_bank + j - state = self.zynseq.libseq.getSequenceState( - self.zynseq.bank, seq) - mode = (state >> 8) & 0xFF - group = (state >> 16) & 0xFF - state &= 0xFF - self.update_seq_state(bank=self.zynseq.bank, seq=seq, state=state, mode=mode, group=group) - + self.light_off() + for row in range(self.rows): + phrase = row + self.scroll_v + for chan in range(32): + self.update_seq_state(phrase, chan) + self.update_seq_state(phrase, zynseq.PHRASE_CHANNEL) + + def on_active_phrase(self, phrase): + """Handle active phrase selection + *COULD* be implemented by child class""" + + if self.scroll_mode == SCROLL_MODE_GUI_SEL: + if phrase < self.scroll_v: + self.scroll_v = phrase + elif phrase >= self.scroll_v + self.rows: + self.scroll_v = max(0, min(self.zynseq.phrases - self.rows, phrase - self.rows + 1)) + else: + return + self.refresh() # ------------------------------------------------------------------------------------------------------------------ # Zynmixer control device base class # ------------------------------------------------------------------------------------------------------------------ + + class zynthian_ctrldev_zynmixer(zynthian_ctrldev_base): dev_zynmixer = True # Can act as a zynmixer trigger device def __init__(self, state_manager, idev_in, idev_out=None): - self.zynmixer = state_manager.zynmixer + self.zynmixer = state_manager.zynmixer_chan + self.zynmixer_bus = state_manager.zynmixer_bus + self.scroll_h = 0 super().__init__(state_manager, idev_in, idev_out) def init(self): super().init() - zynsigman.register_queued( - zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.update_mixer_active_chain) - zynsigman.register_queued( - zynsigman.S_CHAIN_MAN, self.chain_manager.SS_MOVE_CHAIN, self.refresh) - zynsigman.register_queued( - zynsigman.S_AUDIO_MIXER, self.zynmixer.SS_ZCTRL_SET_VALUE, self.update_mixer_strip) + # Register for audio mixer changes + zynsigman.register_queued(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, self.update_mixer_strip) def end(self): - zynsigman.unregister( - zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.update_mixer_active_chain) - zynsigman.unregister(zynsigman.S_CHAIN_MAN, - self.chain_manager.SS_MOVE_CHAIN, self.refresh) - zynsigman.unregister( - zynsigman.S_AUDIO_MIXER, self.zynmixer.SS_ZCTRL_SET_VALUE, self.update_mixer_strip) + # Unregister for audio mixer changes + zynsigman.unregister(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, self.update_mixer_strip) self.light_off() super().end() - def update_mixer_strip(self, chan, symbol, value): + def update_mixer_strip(self, chan, symbol, value, mixbus=False): """Update hardware indicators for a mixer strip: mute, solo, level, balance, etc. *SHOULD* be implemented by child class chan - Mixer strip index symbol - Control name value - Control value + mixbus - True for mixbus mixer. False for chain mixer. (Default: False) """ logging.debug(f"Update mixer strip for {type(self).__name__}: NOT IMPLEMENTED!") - def update_mixer_active_chain(self, active_chain): - """Update hardware indicators for active_chain - *SHOULD* be implemented by child class + def set_mixer_param(self, param, pos, value): + """Set a mixer parameter value + + param - Symbol name of the parameter + pos - Chain display position (-1 for main chain) + value - Parameter value + """ + + if pos < 0: + chain = self.chain_manager.chains[0] + else: + chain = self.get_filtered_chain_by_index(pos) + if chain and chain.zynmixer_proc: + try: + zctrl = chain.zynmixer_proc.controllers_dict[param] + if zctrl.value != value: + zctrl.set_value(value) + except: + logging.warning(f"Failed to set {param} to {value}") + + def nudge_mixer_param(self, param, pos, value): + """Set a mixer parameter value + + param - Symbol name of the parameter + pos - Chain display position (-1 for main chain) + value - Parameter value + """ + + if pos < 0: + chain = self.chain_manager.chains[0] + else: + chain = self.get_filtered_chain_by_index(pos) + if chain and chain.zynmixer_proc: + try: + zctrl = chain.zynmixer_proc.controllers_dict[param] + zctrl.nudge(value) + except: + logging.warning(f"Failed to nudge {param} by {value}") + + def get_mixer_param(self, param, pos): + """Get a mixer parameter value - active_chain - Active chain + param - Symbol name of the parameter + pos - Chain display position (-1 for main chain) + Returns - Parameter value """ - logging.debug(f"Update mixer active chain for {type(self).__name__}: NOT IMPLEMENTED!") + + if pos < 0: + chain = self.chain_manager.chains[0] + else: + chain = self.get_filtered_chain_by_index(pos) + if chain and chain.zynmixer_proc: + try: + return chain.zynmixer_proc.controllers_dict[param].get_value() + except: + pass + #logging.warning(f"Failed to get {param}") + return 0 + + def toggle_mixer_param(self, param, pos): + """Toggle chain mute + + param - Symbol name of the parameter + pos - Chain display position (-1 for main chain) + return - mute state + """ + if pos < 0: + chain = self.chain_manager.chains[0] + else: + chain = self.get_filtered_chain_by_index(pos) + if chain and chain.zynmixer_proc: + try: + chain.zynmixer_proc.controllers_dict[param].toggle() + return chain.zynmixer_proc.controllers_dict[param].value + except: + logging.warning(f"Failed to toggle {param}") + return 0 # -------------------------------------------------------------------------- diff --git a/zyngine/ctrldev/zynthian_ctrldev_base_extended.py b/zyngine/ctrldev/zynthian_ctrldev_base_extended.py index 93a686596..9335df4bb 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_base_extended.py +++ b/zyngine/ctrldev/zynthian_ctrldev_base_extended.py @@ -251,7 +251,7 @@ class ModeHandlerBase: "option": "MENU", "main_menu": "MENU", "admin": "SCREEN_ADMIN", - "audio_mixer": "SCREEN_AUDIO_MIXER", + "mixer": "SCREEN_MIXER", "alsa_mixer": "SCREEN_ALSA_MIXER", "control": "SCREEN_CONTROL", "preset": "PRESET", @@ -268,7 +268,6 @@ class ModeHandlerBase: def __init__(self, state_manager): self._state_manager = state_manager self._chain_manager = state_manager.chain_manager - self._zynmixer = state_manager.zynmixer self._zynseq = state_manager.zynseq self._timer = None @@ -347,8 +346,8 @@ def _on_shifted_override(self, override=None): self._is_shifted = override # FIXME: Could this be in chain_manager? - def _get_chain_id_by_sequence(self, bank, seq): - channel = self._libseq.getChannel(bank, seq, 0) + def _get_chain_id_by_sequence(self, phrase, seq): + channel = self._libseq.getChannel(self._zynseq.scene, phrase, seq, 0) return next( (id for id, c in self._chain_manager.chains.items() if c.midi_chan == channel), @@ -356,34 +355,34 @@ def _get_chain_id_by_sequence(self, bank, seq): ) # FIXME: Could this (or part of this) be in libseq? - def _get_sequence_patterns(self, bank, seq, create=False): - seq_len = self._libseq.getSequenceLength(bank, seq) + def _get_sequence_patterns(self, phrase, seq, create=False): + seq_len = self._libseq.getSequenceLength(self._zynseq.scene, phrase, seq) pattern = -1 retval = [] if seq_len == 0: if create: pattern = self._libseq.createPattern() - self._libseq.addPattern(bank, seq, 0, 0, pattern) + self._libseq.addPattern(self._zynseq.scene, phrase, seq, 0, 0, pattern) retval.append(pattern) return retval - n_tracks = self._libseq.getTracksInSequence(bank, seq) + n_tracks = self._libseq.getTracksInSequence(self._zynseq.scene, phrase, seq) for track in range(n_tracks): - retval.extend(self._get_patterns_in_track(bank, seq, track)) + retval.extend(self._get_patterns_in_track(self._zynseq.scene, phrase, seq, track)) return retval # FIXME: Could this be in libseq? - def _get_patterns_in_track(self, bank, seq, track): + def _get_patterns_in_track(self, phrase, seq, track): retval = [] - n_patts = self._libseq.getPatternsInTrack(bank, seq, track) + n_patts = self._libseq.getPatternsInTrack(self._zynseq.scene, phrase, seq, track) if n_patts == 0: return retval - seq_len = self._libseq.getSequenceLength(bank, seq) + seq_len = self._libseq.getSequenceLength(self._zynseq.scene, phrase, seq) pos = 0 while pos < seq_len: - pattern = self._libseq.getPatternAt(bank, seq, track, pos) + pattern = self._libseq.getPatternAt(self._zynseq.scene, phrase, seq, track, pos) if pattern != -1: retval.append(pattern) pos += self._libseq.getPatternLength(pattern) @@ -393,17 +392,17 @@ def _get_patterns_in_track(self, bank, seq, track): return retval # FIXME: Could this be in libseq? - def _add_pattern_to_end_of_track(self, bank, seq, track, pattern): + def _add_pattern_to_end_of_track(self, phrase, seq, track, pattern): pos = 0 - if self._libseq.getTracksInSequence(bank, seq) != 0: - pos = self._libseq.getSequenceLength(bank, seq) + if self._libseq.getTracksInSequence(self._zynseq.scene, phrase, seq) != 0: + pos = self._libseq.getSequenceLength(self._zynseq.scene, phrase, seq) while pos > 0: # Arranger's offset step is a quarter note (24 clocks) - if self._libseq.getPatternAt(bank, seq, track, pos - 24) != -1: + if self._libseq.getPatternAt(self._zynseq.scene, phrase, seq, track, pos - 24) != -1: break pos -= 24 - return self._libseq.addPattern(bank, seq, track, pos, pattern) + return self._libseq.addPattern(self._zynseq.scene, phrase, seq, track, pos, pattern) # FIXME: Could this be in libseq? def _set_note_duration(self, step, note, duration): @@ -425,7 +424,7 @@ def _show_screen_briefly(self, screen, cuia, timeout): # If screen is audio mixer, there is no 'back', so try to get the screen # name. Not all screens may be mapped, so it will fail there (only corner-cases). - if screen == "audio_mixer": + if screen == "mixer": prev_screen = self.SCREEN_CUIA_MAP.get( self._current_screen, "BACK") diff --git a/zyngine/ctrldev/zynthian_ctrldev_base_ui.py b/zyngine/ctrldev/zynthian_ctrldev_base_ui.py index 7e9a6abc9..3d813c3f3 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_base_ui.py +++ b/zyngine/ctrldev/zynthian_ctrldev_base_ui.py @@ -59,7 +59,7 @@ def _select_pad(self, pad): def _refresh_pattern_editor(self): zynpad = zynthian_gui_config.zyngui.screens["zynpad"] patted = zynthian_gui_config.zyngui.screens['pattern_editor'] - pattern = self._zynseq.libseq.getPattern(zynpad.bank, zynpad.selected_pad, 0, 0) + pattern = self._zynseq.libseq.getPattern(self.zynseq.scene, zynpad.bank, zynpad.selected_pad, 0, 0) self._state_manager.start_busy("load_pattern", f"loading pattern {pattern}") patted.bank = zynpad.bank diff --git a/zyngine/ctrldev/zynthian_ctrldev_fostex_mixtab.py b/zyngine/ctrldev/zynthian_ctrldev_fostex_mixtab.py index 37cd5ffec..9a29ec4c5 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_fostex_mixtab.py +++ b/zyngine/ctrldev/zynthian_ctrldev_fostex_mixtab.py @@ -25,12 +25,13 @@ # ****************************************************************************** import logging -from time import monotonic +from threading import Timer # Zynthian specific modules from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynmixer from zyncoder.zyncore import lib_zyncore from zynlibs.zynseq import zynseq +from zyngine.zynthian_signal_manager import zynsigman # ------------------------------------------------------------------------------ # Fostex MixTab MIDI controller @@ -73,51 +74,77 @@ class zynthian_ctrldev_fostex_mixtab(zynthian_ctrldev_zynmixer): def __init__(self, state_manager, idev_in, idev_out=None): super().__init__(state_manager, idev_in, idev_out) self.midi_chan = 0 # Base channel for MIDI messages. +1 for +8 offset, +2 for +16 offset. - self.chan2chain = {} - self.last_store = monotonic() + self.feedback_timer = None + + def init(self): + # Send the current mixer state to the mixtab allowing "enable" mode to be used + super().init() + # Register for processor tree changes + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_ADD_CHAIN, self.refresh) + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_REMOVE_CHAIN, self.refresh) + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_REMOVE_ALL_CHAINS, self.refresh) + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_MOVE_CHAIN, self.refresh) + + def end(self): + # Unregister from processor tree changes + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_ADD_CHAIN, self.refresh) + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_REMOVE_CHAIN, self.refresh) + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_REMOVE_ALL_CHAINS, self.refresh) + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_MOVE_CHAIN, self.refresh) + super().end() def set_param(self, cc, val, midi_chan): if cc == 7: # Main fader - self.zynmixer.set_level(255, val / 127.0, False) + self.set_mixer_param("level", -1, val / 127) + if 66 <= cc <= 73: + strip = midi_chan * 8 + (cc - 2) % 8 + # Aux + strip = (cc - 66) + (midi_chan * 8) + if val < 64: + # Aux 1 + val *= 2 + param = "send_0" + else: + val = (val - 64) * 2 + param = "send_1" + try: + self.set_mixer_param(param, strip, val / 127.0) + except: + pass + return True if cc < 16 or cc > 31: return False - chain = self.chain_manager.get_chain_by_position( - midi_chan * 8 + cc % 8 , midi=False) - if chain is None or chain.mixer_chan is None or chain.mixer_chan > 15: - return False + strip = midi_chan * 8 + cc % 8 match int(cc / 8): case 2: # Fader - self.zynmixer.set_level(chain.mixer_chan, val / 127.0, False) + self.set_mixer_param("level", strip, val / 127.0) case 3: # Pan - self.zynmixer.set_balance(chain.mixer_chan, (val - 64) / 64, False) + self.set_mixer_param("balance", strip, (val / 64) - 1) return True def get_param(self, cc, midi_chan): if cc == 7: # Main fader - return int(self.zynmixer.get_level(255) * 127) + return int(self.zynmixer_bus.get_level(0) * 127) if cc < 16 or cc > 31: return None - chain = self.chain_manager.get_chain_by_position( - midi_chan * 8 + cc % 8 , midi=False) - if chain is None or chain.mixer_chan is None or chain.mixer_chan > 15: - return None + strip = midi_chan * 8 + cc % 8 match int(cc / 8): case 2: # Fader - return int(self.zynmixer.get_level(chain.mixer_chan) * 127) + return int(self.get_mixer_param("level", strip) * 127) case 3: # Pan - return int(self.zynmixer.get_balance(chain.mixer_chan) * 64) + 64 + return int(self.get_mixer_param("balance", strip) * 64) + 64 return None def midi_event(self, ev): evtype = (ev[0] >> 4) & 0x0F midi_chan = ev[0] & 0xF - if midi_chan > 1: + if midi_chan > 2: return False if evtype == 0xb: cc = ev[1] & 0x7F @@ -131,20 +158,13 @@ def midi_event(self, ev): case 49: # Dump Request parameter 0..126 or 127 for all parameters if val == 127: - for i in range(16, 32): - param_val = self.get_param(i, midi_chan) - if param_val is not None: - lib_zyncore.dev_send_ccontrol_change(self.idev_out, midi_chan, i, param_val) + self.refresh() else: lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, val, self.get_param(val)) return True case 50: # Scene store 0..99 - now = monotonic() - if now < self.last_store + 1.5: - # Double press STORE but second press has to be after display stops flashing - self.state_manager.save_zs3(f"{self.midi_chan}/{val}", "Saved by MIXTAB") - self.last_store = now + self.state_manager.save_zs3(f"{self.midi_chan}/{val}", "Saved by MIXTAB") return True case 51: # Scene clear 0..99 or 127 for all scenes @@ -167,21 +187,36 @@ def midi_event(self, ev): self.state_manager.send_cuia("ZYNPOT_ABS", [2, val/127]) return True return self.set_param(cc, val, midi_chan) + elif evtype == 0xc: + pass + # Let zynthian handle PC return False - def update_mixer_strip(self, chan, symbol, value): - return - if chan in self.chan2chain: - match symbol: - case "level": - lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan + int(chan / 8), 16 + chan % 8 , int(value * 127)) - case "balance": - lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan + int(chan / 8), 24 + chan % 8 , int(value * 64 + 64)) + def update_mixer_strip(self, chan, symbol, value, mixbus): + #TODO: Blunt refresh of all controls after 2s of inactivity + if self.feedback_timer: + self.feedback_timer.cancel() + self.feedback_timer = Timer(2.0, self.refresh) + self.feedback_timer.start() def refresh(self): - self.chan2chain = {} - for chain_id, chain in self.chain_manager.chains.items(): - if chain.mixer_chan is not None and chain.mixer_chan < 16: - self.chan2chain[chain.mixer_chan] = chain_id + if self.feedback_timer: + self.feedback_timer.cancel() + self.feedback_timer = None + for strip in range(8): + chain = self.chain_manager.get_chain_by_index(strip) + if chain: + mixer = chain.zynmixer_proc + if mixer: + zctrl = mixer.controllers_dict["level"] + value = int(zctrl.value * 127) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan + int(strip / 8), 16 + strip % 8 , value) + zctrl = mixer.controllers_dict["balance"] + value = int(zctrl.value * 64 + 64) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan + int(strip / 8), 24 + strip % 8 , value) + + zctrl = self.state_manager.main_mixbus_proc.controllers_dict["level"] + main_level = int(zctrl.value * 127) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, 7, main_level) # ------------------------------------------------------------------------------ diff --git a/zyngine/ctrldev/zynthian_ctrldev_korg_nanokontrol2.py b/zyngine/ctrldev/zynthian_ctrldev_korg_nanokontrol2.py index 1eb683f7b..7a6289543 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_korg_nanokontrol2.py +++ b/zyngine/ctrldev/zynthian_ctrldev_korg_nanokontrol2.py @@ -5,7 +5,7 @@ # # Zynthian Control Device Driver for "Korg nanoKontrol-2" # -# Copyright (C) 2024 Fernando Moyano +# Copyright (C) 2024-2025 Fernando Moyano # # ****************************************************************************** # @@ -30,6 +30,7 @@ from zyncoder.zyncore import lib_zyncore from zyngine.zynthian_signal_manager import zynsigman from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynmixer +from zyngine.zynthian_audio_recorder import zynthian_audio_recorder # -------------------------------------------------------------------------- # Korg nanoKontrol-2 Integration @@ -66,7 +67,7 @@ class zynthian_ctrldev_korg_nanokontrol2(zynthian_ctrldev_zynmixer): # Function to initialise class def __init__(self, state_manager, idev_in, idev_out=None): - self.midimix_bank = 0 + self.fader_bank = 0 super().__init__(state_manager, idev_in, idev_out) def send_sysex(self, data): @@ -137,174 +138,122 @@ def init(self): # Enable LED control self.set_mode_led_external() # Register signals - zynsigman.register_queued( - zynsigman.S_AUDIO_PLAYER, self.state_manager.SS_AUDIO_PLAYER_STATE, self.refresh_audio_transport) - zynsigman.register_queued( - zynsigman.S_AUDIO_RECORDER, self.state_manager.SS_AUDIO_RECORDER_STATE, self.refresh_audio_transport) - zynsigman.register_queued( - zynsigman.S_STATE_MAN, self.state_manager.SS_MIDI_PLAYER_STATE, self.refresh_midi_transport) - zynsigman.register_queued( - zynsigman.S_STATE_MAN, self.state_manager.SS_MIDI_RECORDER_STATE, self.refresh_midi_transport) + zynsigman.register_queued(zynsigman.S_AUDIO_PLAYER, self.state_manager.SS_AUDIO_PLAYER_STATE, self.refresh_audio_transport) + zynsigman.register_queued(zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_STATE, self.refresh_audio_transport) + zynsigman.register_queued(zynsigman.S_STATE_MAN, self.state_manager.SS_MIDI_PLAYER_STATE, self.refresh_midi_transport) + zynsigman.register_queued(zynsigman.S_STATE_MAN, self.state_manager.SS_MIDI_RECORDER_STATE, self.refresh_midi_transport) super().init() def end(self): super().end() # Unregister signals - zynsigman.unregister(zynsigman.S_AUDIO_PLAYER, - self.state_manager.SS_AUDIO_PLAYER_STATE, self.refresh_audio_transport) - zynsigman.unregister(zynsigman.S_AUDIO_RECORDER, - self.state_manager.SS_AUDIO_RECORDER_STATE, self.refresh_audio_transport) - zynsigman.unregister( - zynsigman.S_STATE_MAN, self.state_manager.SS_MIDI_PLAYER_STATE, self.refresh_midi_transport) - zynsigman.unregister( - zynsigman.S_STATE_MAN, self.state_manager.SS_MIDI_RECORDER_STATE, self.refresh_midi_transport) + zynsigman.unregister(zynsigman.S_AUDIO_PLAYER, self.state_manager.SS_AUDIO_PLAYER_STATE, self.refresh_audio_transport) + zynsigman.unregister(zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_STATE, self.refresh_audio_transport) + zynsigman.unregister(zynsigman.S_STATE_MAN, self.state_manager.SS_MIDI_PLAYER_STATE, self.refresh_midi_transport) + zynsigman.unregister(zynsigman.S_STATE_MAN, self.state_manager.SS_MIDI_RECORDER_STATE, self.refresh_midi_transport) def refresh_audio_transport(self, **kwargs): if self.shift: return # REC Button if self.state_manager.audio_recorder.rec_proc: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_rec_ccnum, 0x7F) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_rec_ccnum, 0x7F) else: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_rec_ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_rec_ccnum, 0) # STOP button - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_stop_ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_stop_ccnum, 0) # PLAY button: if self.state_manager.status_audio_player: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_play_ccnum, 0x7F) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_play_ccnum, 0x7F) else: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_play_ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_play_ccnum, 0) def refresh_midi_transport(self, **kwargs): if not self.shift: return # REC Button if self.state_manager.status_midi_recorder: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_rec_ccnum, 0x7F) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_rec_ccnum, 0x7F) else: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_rec_ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_rec_ccnum, 0) # STOP button - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_stop_ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_stop_ccnum, 0) # PLAY button: if self.state_manager.status_midi_player: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_play_ccnum, 0x7F) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_play_ccnum, 0x7F) else: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_play_ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_play_ccnum, 0) # Update LED status for a single strip - def update_mixer_strip(self, chan, symbol, value): - if self.idev_out is None: + def update_mixer_strip(self, chan, symbol, value, mixbus=False): + if self.idev_out is None or mixbus: # Nothing to update in main strip nor other mixbuses implementation return - chain_id = self.chain_manager.get_chain_id_by_mixer_chan(chan) - if chain_id: - col = self.chain_manager.get_chain_index(chain_id) - if self.midimix_bank: - col -= 8 + try: + col = self.chain_manager.get_pos_by_mixer_chan(chan, mixbus) - self.scroll_h if 0 <= col < 8: if symbol == "mute": - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.mute_ccnums[col], value * 0x7F) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.mute_ccnums[col], value * 0x7F) elif symbol == "solo": - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.solo_ccnums[col], value * 0x7F) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.solo_ccnums[col], value * 0x7F) elif symbol == "rec" and self.rec_mode: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.rec_ccnums[col], value * 0x7F) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.rec_ccnums[col], value * 0x7F) + except: + pass # Update LED status for active chain def update_mixer_active_chain(self, active_chain): if self.rec_mode: return - if self.midimix_bank: - col0 = 8 - else: - col0 = 0 for i in range(0, 8): - chain_id = self.chain_manager.get_chain_id_by_index(col0 + i) + chain_id = self.get_filtered_chain_id_by_index(self.scroll_h + i) if chain_id and chain_id == active_chain: rec = 0x7F else: rec = 0 - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.rec_ccnums[i], rec) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.rec_ccnums[i], rec) # Update full LED status def refresh(self): + super().refresh() if self.idev_out is None: return - # Bank selection LED - if self.midimix_bank: - col0 = 8 - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_frwd_ccnum, 0) - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_ffwd_ccnum, 0x7F) + if self.fader_bank: + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_frwd_ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_ffwd_ccnum, 0x7F) else: - col0 = 0 - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_frwd_ccnum, 0x7F) - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.transport_ffwd_ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_frwd_ccnum, 0x7F) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.transport_ffwd_ccnum, 0) if self.shift: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.cycle_ccnum, 0x7F) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.cycle_ccnum, 0x7F) self.refresh_midi_transport() else: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.cycle_ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.cycle_ccnum, 0) self.refresh_audio_transport() # Strips Leds for i in range(0, 8): - chain = self.chain_manager.get_chain_by_index(col0 + i) - - if chain and chain.mixer_chan is not None: - mute = self.zynmixer.get_mute(chain.mixer_chan) * 0x7F - solo = self.zynmixer.get_solo(chain.mixer_chan) * 0x7F + # With shift, master strip in last column + if self.shift and i == 7: + pos = -1 + chain_id = 0 else: - chain = None - mute = 0 - solo = 0 - - if not self.rec_mode: - if chain and chain == self.chain_manager.get_active_chain(): - rec = 0x7F - else: - rec = 0 + pos = self.scroll_h + i + chain_id = self.get_filtered_chain_id_by_index(pos) + mute = self.get_mixer_param("mute", pos) + solo = self.get_mixer_param("solo", pos) + if self.rec_mode: + rec = self.get_mixer_param("record", pos) else: - if chain and chain.mixer_chan is not None: - rec = self.state_manager.audio_recorder.is_armed( - chain.mixer_chan) * 0x7F + if chain_id == self.chain_manager.get_active_chain().chain_id: + rec = 1 else: rec = 0 - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.mute_ccnums[i], mute) - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.solo_ccnums[i], solo) - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, self.rec_ccnums[i], rec) - - def get_mixer_chan_from_device_col(self, col): - if self.midimix_bank: - col += 8 - chain = self.chain_manager.get_chain_by_index(col) - if chain: - return chain.mixer_chan - else: - return None + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.mute_ccnums[i], mute * 0x7F) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.solo_ccnums[i], solo * 0x7F) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, self.rec_ccnums[i], rec * 0x7F) def midi_event(self, ev): evtype = (ev[0] >> 4) & 0x0F @@ -347,15 +296,17 @@ def midi_event(self, ev): return True elif ccnum == self.transport_frwd_ccnum: if ccval > 0: - if self.midimix_bank == 0: + if self.fader_bank == 0: self.state_manager.send_cuia("BACK") else: - self.midimix_bank = 0 + self.fader_bank = 0 + self.scroll_h = 0 self.refresh() return True elif ccnum == self.transport_ffwd_ccnum: if ccval > 0: - self.midimix_bank = 1 + self.fader_bank = 1 + self.scroll_h = 8 self.refresh() return True elif ccnum == self.transport_play_ccnum: @@ -383,85 +334,49 @@ def midi_event(self, ev): if ccval > 0: col = self.mute_ccnums.index(ccnum) if self.shift and col == 7: - mixer_chan = 255 + val = self.toggle_mixer_param("mute", -1) else: - mixer_chan = self.get_mixer_chan_from_device_col(col) - if mixer_chan is not None: - if self.zynmixer.get_mute(mixer_chan): - val = 0 - else: - val = 1 - self.zynmixer.set_mute(mixer_chan, val, True) - # Send LED feedback - if self.idev_out is not None: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, ccnum, val * 0x7F) - elif self.idev_out is not None: - # If not associated mixer channel, turn-off the led - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, ccnum, 0) + pos = self.scroll_h + col + val = self.toggle_mixer_param("mute", pos) + # Send LED feedback + if self.idev_out is not None: + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, ccnum, val * 0x7F) return True elif ccnum in self.solo_ccnums: if ccval > 0: col = self.solo_ccnums.index(ccnum) if self.shift and col == 7: - mixer_chan = 255 + val = self.toggle_mixer_param("solo", -1) else: - mixer_chan = self.get_mixer_chan_from_device_col(col) - if mixer_chan is not None: - if self.zynmixer.get_solo(mixer_chan): - val = 0 - else: - val = 1 - self.zynmixer.set_solo(mixer_chan, val, True) - # Send LED feedback - if self.idev_out is not None: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, ccnum, val * 0x7F) - elif self.idev_out is not None: - # If not associated mixer channel, turn-off the led - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, ccnum, 0) + pos = self.scroll_h + col + val = self.toggle_mixer_param("solo", pos) + # Send LED feedback + if self.idev_out is not None: + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, ccnum, val * 0x7F) return True elif ccnum in self.rec_ccnums: if ccval > 0: col = self.rec_ccnums.index(ccnum) + pos = self.scroll_h + col if not self.rec_mode: - if self.midimix_bank: - col += 8 - self.chain_manager.set_active_chain_by_index(col) + self.chain_manager.set_active_chain_by_index(pos) self.refresh() else: - mixer_chan = self.get_mixer_chan_from_device_col(col) - if mixer_chan is not None: - self.state_manager.audio_recorder.toggle_arm( - mixer_chan) - # Send LED feedback - if self.idev_out is not None: - val = self.state_manager.audio_recorder.is_armed( - mixer_chan) * 0x7F - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, ccnum, val) - elif self.idev_out is not None: - # If not associated mixer channel, turn-off the led - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, ccnum, 0) + val = self.toggle_mixer_param("record", pos) + # Send LED feedback + if self.idev_out is not None: + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, ccnum, val * 0x7F) return True - # elif ccnum == self.master_ccnum: - # self.zynmixer.set_level(255, ccval / 127.0) - # return True elif ccnum in self.faders_ccnum: col = self.faders_ccnum.index(ccnum) # With "shift" ... if self.shift and col == 7: # use last fader to control Main volume (right) - self.zynmixer.set_level(255, ccval / 127.0) + self.set_mixer_param("level", -1, ccval / 127) # else, use faders to control chain's volume else: - mixer_chan = self.get_mixer_chan_from_device_col(col) - if mixer_chan is not None: - self.zynmixer.set_level( - mixer_chan, ccval / 127.0, True) + pos = self.scroll_h + col + self.set_mixer_param("level", pos, ccval / 127) return True elif ccnum in self.knobs_ccnum: col = self.knobs_ccnum.index(ccnum) @@ -469,17 +384,14 @@ def midi_event(self, ev): if self.shift: # use last knob to control Main balance if col == 7: - self.zynmixer.set_balance( - 255, 2.0 * ccval / 127.0 - 1.0) + self.set_mixer_param("balance", -1, 2.0 * ccval / 127.0 - 1.0) # pass rest of knob's CC to engine control (MIDI-learn) else: return False # else, use knobs to control chain's balance else: - mixer_chan = self.get_mixer_chan_from_device_col(col) - if mixer_chan is not None: - self.zynmixer.set_balance( - mixer_chan, 2.0 * ccval/127.0 - 1.0) + pos = self.scroll_h + col + self.set_mixer_param("balance", pos, 2.0 * ccval/127.0 - 1.0) return True # SysEx elif ev[0] == 0xF0: @@ -493,18 +405,13 @@ def midi_event(self, ev): def light_off(self): if self.idev_out is None: return - for ccnum in self.mute_ccnums: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, ccnum, 0) for ccnum in self.solo_ccnums: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, ccnum, 0) for ccnum in self.rec_ccnums: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, ccnum, 0) for ccnum in [41, 42, 43, 44, 45, 46, 58, 59, 60, 61, 62]: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, self.midi_chan, ccnum, 0) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, ccnum, 0) # ------------------------------------------------------------------------------ diff --git a/zyngine/ctrldev/zynthian_ctrldev_launchkey_mini_mk3.py b/zyngine/ctrldev/zynthian_ctrldev_launchkey_mini_mk3.py index b9219eea3..04b26f85a 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_launchkey_mini_mk3.py +++ b/zyngine/ctrldev/zynthian_ctrldev_launchkey_mini_mk3.py @@ -30,6 +30,9 @@ from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynpad, zynthian_ctrldev_zynmixer from zyncoder.zyncore import lib_zyncore from zynlibs.zynseq import zynseq +from zyngui import zynthian_gui_config +from zyngine.zynthian_chain_manager import MAX_NUM_MIDI_CHANS +from zyngine.zynthian_signal_manager import zynsigman # ------------------------------------------------------------------------------------------------------------------ # Novation Launchkey Mini MK3 @@ -42,21 +45,33 @@ class zynthian_ctrldev_launchkey_mini_mk3(zynthian_ctrldev_zynpad, zynthian_ctrl driver_name = "Launchkey Mini Mk3" driver_description = "Interface Novation Launchkey Mini Mk3 with zynpad and zynmixer" - PAD_COLOURS = [71, 104, 76, 51, 104, 41, - 64, 12, 11, 71, 4, 67, 42, 9, 105, 15] - STARTING_COLOUR = 123 - STOPPING_COLOUR = 120 + POT_MODE_CUSTOM_0 = 0 + POT_MODE_VOLUME = 1 + POT_MODE_DEVICE = 2 + POT_MODE_PAN = 3 + POT_MODE_SEND_A = 4 + POT_MODE_SEND_B = 5 + POT_MODE_CUSTOM_0 = 6 + POT_MODE_CUSTOM_1 = 7 + POT_MODE_CUSTOM_2 = 8 + POT_MODE_CUSTOM_3 = 9 # Function to initialise class def __init__(self, state_manager, idev_in, idev_out=None): - self.shift = False super().__init__(state_manager, idev_in, idev_out) + self.cols = 8 + self.rows = 2 + self.shift = False + self.pot_mode = self.POT_MODE_VOLUME # Potentiometer mode + self.mixer_toggle = False # Used to toggle mixer / launcher view def init(self): # Enable session mode on launchkey lib_zyncore.dev_send_note_on(self.idev_out, 15, 12, 127) - self.cols = 8 - self.rows = 2 + # Set pots to volume control + self.pot_mode = self.POT_MODE_VOLUME + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 15, 9, self.pot_mode) + self.mixer_toggle = False super().init() def end(self): @@ -64,37 +79,40 @@ def end(self): # Disable session mode on launchkey lib_zyncore.dev_send_note_on(self.idev_out, 15, 12, 0) - def update_seq_state(self, bank, seq, state, mode, group): - if self.idev_out is None or bank != self.zynseq.bank: - return - col, row = self.zynseq.get_xy_from_pad(seq) - if row > 1: + def light_off(self): + for row in range(self.rows): + for col in range(8): + self.pad_off(col, row) + + def update_pad(self, row, col, pad_info): + if col == self.cols: # Phrase launcher not implemented! return note = 96 + row * 16 + col + chan = 0 # chan: 0=static, 1=flashing, 2=pulsing + vel = 0 try: - if mode == 0 or group > 16: - chan = 0 + state = pad_info["state"] + repeat = pad_info["repeat"] + group = pad_info["group"] + if repeat == 0 or chan >= MAX_NUM_MIDI_CHANS: vel = 0 + chan = 0 elif state == zynseq.SEQ_STOPPED: + vel = zynthian_gui_config.LAUNCHER_COLOUR[group]["launchpad"] chan = 0 - vel = self.PAD_COLOURS[group] elif state == zynseq.SEQ_PLAYING: + vel = zynthian_gui_config.LAUNCHER_COLOUR[group]["launchpad"] chan = 2 - vel = self.PAD_COLOURS[group] - elif state in [zynseq.SEQ_STOPPING, zynseq.SEQ_STOPPINGSYNC]: + elif state in [zynseq.SEQ_STOPPING, zynseq.SEQ_STOPPING_SYNC]: + vel = zynthian_gui_config.LAUNCHER_STOPPING_COLOUR["launchpad"] chan = 1 - vel = self.STOPPING_COLOUR elif state == zynseq.SEQ_STARTING: + vel = zynthian_gui_config.LAUNCHER_STARTING_COLOUR["launchpad"] + lib_zyncore.dev_send_note_on(self.idev_out, 0, note, vel) + vel = zynthian_gui_config.LAUNCHER_COLOUR[group]["launchpad"] chan = 1 - vel = self.STARTING_COLOUR - else: - chan = 0 - vel = 0 - except Exception as e: - chan = 0 - vel = 0 - # logging.warning(e) - + except: + pass lib_zyncore.dev_send_note_on(self.idev_out, chan, note, vel) def pad_off(self, col, row): @@ -102,62 +120,93 @@ def pad_off(self, col, row): lib_zyncore.dev_send_note_on(self.idev_out, 0, note, 0) def midi_event(self, ev): + if self.state_manager.power_save_mode: + return True evtype = (ev[0] >> 4) & 0x0F + chan = ev[0] & 0x0f if evtype == 0x9: note = ev[1] & 0x7F - # Entered session mode so set pad LEDs - # QUESTION: What kind of message is this? Only SysEx messages can be bigger than 3 bytes. - # if ev == b'\x90\x90\x0C\x7F': - # self.update_seq_bank() + if ev == b'\x9f\x0C\x7F': + # Ignore tally of the request to put the device into DAW mode + return True # Toggle pad try: - col = (note - 96) // 16 - row = (note - 96) % 16 - pad = row * self.zynseq.col_in_bank + col - if pad < self.zynseq.seq_in_bank: - self.zynseq.libseq.togglePlayState(self.zynseq.bank, pad) + col = (note - 96) % 16 + midi_chan = self.get_filtered_midi_chan_by_index(col) + if midi_chan is not None: + row = (note - 96) // 16 + phrase = row + self.scroll_v + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, midi_chan) except: pass elif evtype == 0xB: ccnum = ev[1] & 0x7F ccval = ev[2] & 0x7F + if chan == 0xf: + if ccval == 0: + return True # Ignore button release + if ccnum == 9: + self.pot_mode = ccval + elif 20 < ccnum < 29: + # Pots + if self.shift: + # Add 8 extra pots with shift + pot = ccnum - 13 + else: + pot = ccnum - 21 + match self.pot_mode: + case self.POT_MODE_VOLUME: + self.set_mixer_param("level", pot, ccval / 127.0) + case self.POT_MODE_PAN: + self.set_mixer_param("balance", pot, 2 * ccval / 127.0 - 1) + case self.POT_MODE_DEVICE: + return False + elif ccnum == 0x66: + # TRACK RIGHT + self.state_manager.send_cuia("ARROW_RIGHT") + elif ccnum == 0x67: + # TRACK LEFT + self.state_manager.send_cuia("ARROW_LEFT") + elif ccnum == 0x73: + # PLAY + if self.shift: + self.state_manager.send_cuia("TOGGLE_MIDI_PLAY") + else: + self.state_manager.send_cuia("TOGGLE_PLAY") + elif ccnum == 0x75: + # RECORD + if self.shift: + self.state_manager.send_cuia("TOGGLE_MIDI_RECORD") + else: + self.state_manager.send_cuia("TOGGLE_RECORD") + return True + if ccnum == 0x6C: # SHIFT self.shift = ccval != 0 elif ccnum == 0 or ccval == 0: - return True - elif (self.shift and 20 < ccnum < 29) or (20 < ccnum < 25): - chain = self.chain_manager.get_chain_by_position( - ccnum - 21, midi=False) - if chain and chain.mixer_chan is not None and chain.mixer_chan < 17: - self.zynmixer.set_level(chain.mixer_chan, ccval / 127.0) - elif 24 < ccnum < 29: - self.state_manager.send_cuia("ZYNPOT_ABS", [ccnum - 25, ccval/127]) - elif ccnum == 0x66: - # TRACK RIGHT - self.state_manager.send_cuia("ARROW_RIGHT") - elif ccnum == 0x67: - # TRACK LEFT - self.state_manager.send_cuia("ARROW_LEFT") + return True # Ignore Modulation CC and button release elif ccnum == 0x68: - # UP - self.state_manager.send_cuia("ARROW_UP") - elif ccnum == 0x69: - # DOWN - self.state_manager.send_cuia("ARROW_DOWN") - elif ccnum == 0x73: - # PLAY if self.shift: - self.state_manager.send_cuia("TOGGLE_MIDI_PLAY") + # UP + self.zynseq.select_phrase(self.zynseq.phrase - 1) + self.refresh() else: - self.state_manager.send_cuia("TOGGLE_PLAY") - elif ccnum == 0x75: - # RECORD + # Scene (Phrase) launcher + self.zynseq.libseq.togglePlayState(self.zynseq.scene, self.zynseq.phrase, zynseq.PHRASE_CHANNEL) + elif ccnum == 0x69: if self.shift: - self.state_manager.send_cuia("TOGGLE_MIDI_RECORD") + # DOWN + self.zynseq.select_phrase(self.zynseq.phrase + 1) + self.refresh() else: - self.state_manager.send_cuia("TOGGLE_RECORD") + # Stop Solo Mute button + if self.mixer_toggle: + self.state_manager.send_cuia("show_screen", ["launcher"]) + else: + self.state_manager.send_cuia("show_screen", ["mixer"]) + self.mixer_toggle = not self.mixer_toggle elif evtype == 0xC: val1 = ev[1] & 0x7F self.zynseq.select_bank(val1 + 1) diff --git a/zyngine/ctrldev/zynthian_ctrldev_launchkey_mini_mk4_37.py b/zyngine/ctrldev/zynthian_ctrldev_launchkey_mini_mk4_37.py index a1cf45756..d80587bdb 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_launchkey_mini_mk4_37.py +++ b/zyngine/ctrldev/zynthian_ctrldev_launchkey_mini_mk4_37.py @@ -89,7 +89,7 @@ def init(self): # Register callbacks for real-time updates using zynsigman zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.update_pad_leds) zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_MOVE_CHAIN, self.update_pad_leds) - zynsigman.register_queued(zynsigman.S_AUDIO_MIXER, self.zynmixer.SS_ZCTRL_SET_VALUE, self.update_mixer_strip) + zynsigman.register_queued(zynsigman.S_MIXER, self.zynmixer.SS_ZCTRL_SET_VALUE, self.update_mixer_strip) zynsigman.register_queued(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_SCREEN, self.on_screen_change) def refresh(self): @@ -115,7 +115,7 @@ def end(self): # Unregister signal callbacks zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.update_pad_leds) zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_MOVE_CHAIN, self.update_pad_leds) - zynsigman.unregister(zynsigman.S_AUDIO_MIXER, self.zynmixer.SS_ZCTRL_SET_VALUE, self.update_mixer_strip) + zynsigman.unregister(zynsigman.S_MIXER, self.zynmixer.SS_ZCTRL_SET_VALUE, self.update_mixer_strip) zynsigman.unregister(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_SCREEN, self.on_screen_change) super().end() # Disable DAW mode on launchkey @@ -347,7 +347,7 @@ def midi_event(self, ev): new_index = (current_index + delta) % len(processor.preset_list) processor.set_preset(new_index) self.state_manager.send_cuia("refresh_screen", ["control"]) - self.state_manager.send_cuia("refresh_screen", ["audio_mixer"]) + self.state_manager.send_cuia("refresh_screen", ["mixer"]) except Exception as e: logging.warning(f"Preset browsing error: {e}") return True diff --git a/zyngine/ctrldev/zynthian_ctrldev_launchkey_mk4_37.py b/zyngine/ctrldev/zynthian_ctrldev_launchkey_mk4_37.py index 1a1cf6ad8..decb1d812 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_launchkey_mk4_37.py +++ b/zyngine/ctrldev/zynthian_ctrldev_launchkey_mk4_37.py @@ -119,8 +119,8 @@ def midi_event(self, ev): col = (note - 96) // 16 row = (note - 96) % 16 pad = row * self.zynseq.col_in_bank + col - if pad < self.zynseq.seq_in_bank: - self.zynseq.libseq.togglePlayState(self.zynseq.bank, pad) + if pad < self.zynseq.seq_in_scene: + self.zynseq.libseq.togglePlayState(self.zynseq.scene, pad) except: pass elif evtype == 0xB: diff --git a/zyngine/ctrldev/zynthian_ctrldev_launchpad_mini.py b/zyngine/ctrldev/zynthian_ctrldev_launchpad_mini.py index 882b0cfee..5fc66de2d 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_launchpad_mini.py +++ b/zyngine/ctrldev/zynthian_ctrldev_launchpad_mini.py @@ -27,10 +27,10 @@ import logging # Zynthian specific modules -from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynpad -from zyngine.zynthian_signal_manager import zynsigman -from zyncoder.zyncore import lib_zyncore from zynlibs.zynseq import zynseq +from zyncoder.zyncore import lib_zyncore +from zyngine.zynthian_chain_manager import MAX_NUM_MIDI_CHANS +from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynpad # ------------------------------------------------------------------------------------------------------------------ # Novation Launchpad Mini MK1 @@ -40,6 +40,8 @@ class zynthian_ctrldev_launchpad_mini(zynthian_ctrldev_zynpad): dev_ids = ["Launchpad Mini IN 1"] + driver_name = "Launchpad Mini" + driver_description = "Interface Novation Launchpad Mini with launcher." OFF_COLOUR = 0xC # Light Off PLAYING_COLOUR = 0x3C # Solid Green @@ -50,80 +52,62 @@ class zynthian_ctrldev_launchpad_mini(zynthian_ctrldev_zynpad): STOPPING_COLOUR = 0x0B # Blinking Red ACTIVE_COLOUR = 0x3C # Solid Green - def get_note_xy(self, note): - row = note // 16 - col = note % 16 - return col, row - def init(self): super().init() - zynsigman.register_queued( - zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.update_active_chain) # Configure blinking LEDs lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, 0, 0x28) def end(self): - zynsigman.unregister( - zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.update_active_chain) super().end() - def refresh(self): - super().refresh() - self.update_active_chain() + def get_note_xy(self, note): + row = note // 16 + col = note % 16 + return col, row - def update_active_chain(self, active_chain=None): + def on_active_chain(self, active_chain_id=None): if self.idev_out is None: return - if active_chain is None: - active_chain = self.chain_manager.active_chain_id + if active_chain_id is None: + active_chain_id = self.chain_manager.active_chain.chain_id for col in range(self.cols): chain_id = self.chain_manager.get_chain_id_by_index(col) - if chain_id and chain_id == active_chain: + if chain_id and chain_id == active_chain_id: light = self.ACTIVE_COLOUR else: light = self.OFF_COLOUR - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, 0, 104 + col, light) - - def update_seq_bank(self): - if self.idev_out is None: - return - # logging.debug("Updating Launchpad MINI bank leds") - col = 8 - for row in range(self.rows): - note = 16 * row + col - if row == self.zynseq.bank - 1: - lib_zyncore.dev_send_note_on( - self.idev_out, 0, note, self.ACTIVE_COLOUR) - else: - lib_zyncore.dev_send_note_on( - self.idev_out, 0, note, self.OFF_COLOUR) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, 104 + col, light) - def update_seq_state(self, bank, seq, state, mode, group): - if self.idev_out is None or bank != self.zynseq.bank: - return - # logging.debug("Updating Launchpad MINI pad {}".format(seq)) - col, row = self.zynseq.get_xy_from_pad(seq) + def update_pad(self, row, col, pad_info): note = 16 * row + col - chan = 0 - if mode == 0: - vel = self.OFF_COLOUR - elif state == zynseq.SEQ_STOPPED: - vel = self.STOPPED_COLOUR - elif state == zynseq.SEQ_PLAYING: - vel = self.PLAYING_COLOUR - elif state == zynseq.SEQ_STOPPING: - vel = self.STOPPING_COLOUR - elif state == zynseq.SEQ_STARTING: - vel = self.STARTING_COLOUR - else: - vel = self.OFF_COLOUR - lib_zyncore.dev_send_note_on(self.idev_out, chan, note, vel) + midi_chan = 0 + vel = self.OFF_COLOUR + try: + state = pad_info["state"] + if col == self.cols: + group = 32 + else: + group = pad_info["group"] + if pad_info["repeat"] == 0 or group > MAX_NUM_MIDI_CHANS: + pass + elif state == zynseq.SEQ_STOPPED: + if not pad_info["empty"]: + vel = self.STOPPED_COLOUR + elif state == zynseq.SEQ_PLAYING: + vel = self.PLAYING_COLOUR + elif state == zynseq.SEQ_STOPPING: + vel = self.STOPPING_COLOUR + elif state == zynseq.SEQ_STARTING: + vel = self.STARTING_COLOUR + else: + vel = self.OFF_COLOUR + except: + pass + lib_zyncore.dev_send_note_on(self.idev_out, midi_chan, note, vel) - # Light-Off the pad specified with column & row - def pad_off(self, col, row): - note = 16 * row + col - lib_zyncore.dev_send_note_on(self.idev_out, 0, note, 0xC) + def refresh(self): + super().refresh() + self.on_active_chain() def midi_event(self, ev): # logging.debug("Launchpad MINI MIDI handler => {}".format(ev)) @@ -131,15 +115,17 @@ def midi_event(self, ev): if evtype == 0x9: note = ev[1] & 0x7F col, row = self.get_note_xy(note) - # scene change if col == 8: - self.zynseq.select_bank(row + 1) - return True - # launch/stop pad - pad = self.zynseq.get_pad_from_xy(col, row) - if pad >= 0: - self.zynseq.libseq.togglePlayState(self.zynseq.bank, pad) - return True + midi_chan = zynseq.PHRASE_CHANNEL + else: + midi_chan = self.get_filtered_midi_chan_by_index(col) + if midi_chan is not None: + phrase = row + self.scroll_v + try: + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, midi_chan) + except: + pass + return True elif evtype == 0xB: ccnum = ev[1] & 0x7F ccval = ev[2] & 0x7F @@ -148,21 +134,18 @@ def midi_event(self, ev): self.chain_manager.set_active_chain_by_index(ccnum-104) return True + # Light-Off the pad specified with column & row + def pad_off(self, col, row): + note = 16 * row + col + lib_zyncore.dev_send_note_on(self.idev_out, 0, note, 0xC) + # Light-Off all LEDs def light_off(self): for row in range(self.rows): for col in range(self.cols + 1): note = 16 * row + col - lib_zyncore.dev_send_note_on( - self.idev_out, 0, note, self.OFF_COLOUR) + lib_zyncore.dev_send_note_on(self.idev_out, 0, note, self.OFF_COLOUR) for col in range(self.cols): - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, 0, 104 + col, self.OFF_COLOUR) - - def sleep_on(self): - self.light_off() - - def sleep_off(self): - self.refresh() + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, 104 + col, self.OFF_COLOUR) # ------------------------------------------------------------------------------ diff --git a/zyngine/ctrldev/zynthian_ctrldev_launchpad_mini_mk3.py b/zyngine/ctrldev/zynthian_ctrldev_launchpad_mini_mk3.py index 0797535e2..c202fe1ee 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_launchpad_mini_mk3.py +++ b/zyngine/ctrldev/zynthian_ctrldev_launchpad_mini_mk3.py @@ -5,7 +5,7 @@ # # Zynthian Control Device Driver for "Novation Launchpad Mini MK3" # -# Copyright (C) 2015-2024 Fernando Moyano +# Copyright (C) 2015-2025 Fernando Moyano # Brian Walton # # ****************************************************************************** @@ -30,7 +30,9 @@ # Zynthian specific modules from zynlibs.zynseq import zynseq from zyncoder.zyncore import lib_zyncore +from zyngine.zynthian_chain_manager import MAX_NUM_MIDI_CHANS from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynpad +from zyngui import zynthian_gui_config # ------------------------------------------------------------------------------------------------------------------ # Novation Launchpad Mini MK3 @@ -40,14 +42,7 @@ class zynthian_ctrldev_launchpad_mini_mk3(zynthian_ctrldev_zynpad): dev_ids = ["Launchpad Mini MK3 IN 1"] - driver_description = "Zynpad + arrow keys integration" - - PAD_COLOURS = [6, 29, 17, 49, 66, 41, 23, - 13, 96, 2, 81, 82, 83, 84, 85, 86, 87] - STARTING_COLOUR = 21 - STOPPING_COLOUR = 5 - SELECTED_BANK_COLOUR = 29 - STOP_ALL_COLOUR = 5 + driver_description = "Launcher + arrow keys integration" def send_sysex(self, data): if self.idev_out is not None: @@ -77,56 +72,50 @@ def end(self): # Select Keys layout (drums = 0x04, keys = 0x05, user = 0x06, prog = 0x7F) self.send_sysex("00 05") - def update_seq_bank(self): - if self.idev_out is None: - return - # logging.debug("Updating Launchpad MINI MK3 bank leds") - for row in range(0, 7): - note = 89 - 10 * row - if row == self.zynseq.bank - 1: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, 0, note, self.SELECTED_BANK_COLOUR) - else: - lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, note, 0) - # Stop All button => Solid Red - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, 0, 19, self.STOP_ALL_COLOUR) - - def update_seq_state(self, bank, seq, state, mode, group): - if self.idev_out is None or bank != self.zynseq.bank: - return - # logging.debug(f"Updating Launchpad MINI MK3 bank {bank} pad {seq} => state {state}, mode {mode}") - col, row = self.zynseq.get_xy_from_pad(seq) - note = 10 * (8 - row) + col + 1 + def update_pad(self, row, col, pad_info): + midi_chan = 0 + color = 0 try: - if mode == 0: - chan = 0 - vel = 0 + state = pad_info["state"] + if col == self.cols: + group = 32 + else: + group = pad_info["group"] + if pad_info["repeat"] == 0 or group > MAX_NUM_MIDI_CHANS: + pass elif state == zynseq.SEQ_STOPPED: - chan = 0 - vel = self.PAD_COLOURS[group] - elif state == zynseq.SEQ_PLAYING: - chan = 2 - vel = self.PAD_COLOURS[group] - elif state == zynseq.SEQ_STOPPING: - chan = 1 - vel = self.STOPPING_COLOUR + if not pad_info["empty"]: + color = zynthian_gui_config.LAUNCHER_COLOUR[group]["launchpad"] + elif state in (zynseq.SEQ_PLAYING, zynseq.SEQ_CHILD_PLAYING): + midi_chan = 2 + if col == self.cols: + color = zynthian_gui_config.LAUNCHER_STARTING_COLOUR["launchpad"] + else: + color = zynthian_gui_config.LAUNCHER_COLOUR[group]["launchpad"] + elif state in (zynseq.SEQ_STOPPING, zynseq.SEQ_STOPPING_SYNC, zynseq.SEQ_FORCED_STOP, zynseq.SEQ_CHILD_STOPPING): + midi_chan = 1 + color = zynthian_gui_config.LAUNCHER_STOPPING_COLOUR["launchpad"] elif state == zynseq.SEQ_STARTING: - chan = 1 - vel = self.STARTING_COLOUR - else: - chan = 0 - vel = 0 + midi_chan = 1 + color = zynthian_gui_config.LAUNCHER_STARTING_COLOUR["launchpad"] except: - chan = 0 - vel = 0 - # logging.debug("Lighting PAD {}, group {} => {}, {}, {}".format(seq, group, chan, note, vel)) - lib_zyncore.dev_send_note_on(self.idev_out, chan, note, vel) - - # Light-Off the pad specified with column & row + pass + # Send MIDI event to controller + if col < self.cols: + note = 10 * (8 - row) + col + 1 + lib_zyncore.dev_send_note_on(self.idev_out, midi_chan, note, color) + elif col == self.cols: + ccnum = 89 - 10 * row + lib_zyncore.dev_send_ccontrol_change(self.idev_out, midi_chan, ccnum, color) + + # Light-Off the pad specified with chan & phrase (column & row) def pad_off(self, col, row): - note = 10 * (8 - row) + col + 1 - lib_zyncore.dev_send_note_on(self.idev_out, 0, note, 0) + if col < zynseq.PHRASE_CHANNEL: + note = 10 * (8 - row) + col + 1 + lib_zyncore.dev_send_note_on(self.idev_out, 0, note, 0) + elif col == zynseq.PHRASE_CHANNEL: + ccnum = 89 - 10 * row + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, ccnum, 0) def midi_event(self, ev): # logging.debug(f"Launchpad MINI MK3 MIDI handler => {ev}") @@ -137,11 +126,15 @@ def midi_event(self, ev): vel = ev[2] & 0x7F if vel > 0: col, row = self.get_note_xy(note) - pad = self.zynseq.get_pad_from_xy(col, row) - if pad >= 0: - self.zynseq.libseq.togglePlayState(self.zynseq.bank, pad) + midi_chan = self.get_filtered_midi_chan_by_index(col) + if midi_chan is not None: + phrase = row + self.scroll_v + try: + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, midi_chan) + except: + pass return True - # CC => arrows, scene change, stop all + # CC => arrows & phrases elif evtype == 0xB: ccnum = ev[1] & 0x7F ccval = ev[2] & 0x7F @@ -157,10 +150,11 @@ def midi_event(self, ev): else: col, row = self.get_note_xy(ccnum) if col == 8: - if row < 7: - self.zynseq.select_bank(row + 1) - elif row == 7: - self.zynseq.libseq.stop() + try: + phrase = row + self.scroll_v + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, zynseq.PHRASE_CHANNEL) + except: + pass return True # SysEx elif ev[0] == 0xF0: diff --git a/zyngine/ctrldev/zynthian_ctrldev_launchpad_pro_mk2.py b/zyngine/ctrldev/zynthian_ctrldev_launchpad_pro_mk2.py index e399db0f9..33d24bb0b 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_launchpad_pro_mk2.py +++ b/zyngine/ctrldev/zynthian_ctrldev_launchpad_pro_mk2.py @@ -5,7 +5,7 @@ # # Zynthian Control Device Driver for "Novation Launchpad Pro MK2" # -# Copyright (C) 2015-2023 Fernando Moyano +# Copyright (C) 2015-2025 Fernando Moyano # Brian Walton # # ****************************************************************************** @@ -31,6 +31,7 @@ from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynpad from zyncoder.zyncore import lib_zyncore from zynlibs.zynseq import zynseq +from zyngui import zynthian_gui_config # ------------------------------------------------------------------------------------------------------------------ # Novation Launchpad Pro MK2 @@ -40,11 +41,7 @@ class zynthian_ctrldev_launchpad_pro_mk2(zynthian_ctrldev_zynpad): dev_ids = ["Launchpad Pro IN 1"] - - PAD_COLOURS = [6, 29, 17, 49, 66, 41, 23, - 13, 96, 2, 81, 82, 83, 84, 85, 86, 87] - STARTING_COLOUR = 21 - STOPPING_COLOUR = 5 + driver_description = "Launcher + arrow keys integration" def send_sysex(self, data): if self.idev_out is not None: @@ -71,51 +68,34 @@ def end(self): # Select Notes/Drum layout, page 0 (Chord = 0x2, Note/Drum = 0x4, Scale Settings = 0x5, ...) self.send_sysex("22 02") - # Zynpad Scene LED feedback - def refresh_zynpad_bank(self): - if self.idev_out is None: - return - # logging.debug("Updating Launchpad Pro MK2 bank leds") - for row in range(0, 8): - note = 89 - 10 * row - if row == self.zynseq.bank - 1: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, 0, note, 29) - else: - lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, note, 0) - - # Zynpad Pad LED feedback - def update_pad(self, pad, state, mode): - if self.idev_out is None: - return - # logging.debug("Updating LaunchpadPro MK2 pad {}".format(pad)) - col, row = self.zynseq.get_xy_from_pad(pad) + def update_pad(self, row, col, pad_info): + chan = 0 + vel = 0 note = 10 * (8 - row) + col + 1 - - group = self.zynseq.libseq.getGroup(self.zynseq.bank, pad) try: - if mode == 0: - chan = 0 - vel = 0 + state = pad_info["state"] + mode = pad_info["mode"] + repeat = pad_info["repeat"] + if col == self.cols: + group = 0 + else: + group = pad_info["group"] + if repeat == 0 or mode == 0 or group >= MAX_NUM_MIDI_CHANS: + pass elif state == zynseq.SEQ_STOPPED: chan = 0 - vel = self.PAD_COLOURS[group] + vel = zynthian_gui_config.LAUNCHER_COLOUR[group]["launchpad"] elif state == zynseq.SEQ_PLAYING: chan = 2 - vel = self.PAD_COLOURS[group] + vel = zynthian_gui_config.LAUNCHER_COLOUR[group]["launchpad"] elif state == zynseq.SEQ_STOPPING: chan = 1 - vel = self.STOPPING_COLOUR + vel = zynthian_gui_config.LAUNCHER_STOPPING_COLOUR["launchpad"] elif state == zynseq.SEQ_STARTING: chan = 1 - vel = self.STARTING_COLOUR - else: - chan = 0 - vel = 0 + vel = zynthian_gui_config.LAUNCHER_STARTING_COLOUR["launchpad"] except: - chan = 0 - vel = 0 - # logging.debug("Lighting PAD {}, group {} => {}, {}, {}".format(pad, group, chan, note, vel)) + pass lib_zyncore.dev_send_note_on(self.idev_out, chan, note, vel) def midi_event(self, ev): @@ -127,27 +107,35 @@ def midi_event(self, ev): vel = ev[2] & 0x7F if vel > 0: col, row = self.get_note_xy(note) - pad = self.zynseq.get_pad_from_xy(col, row) - if pad >= 0: - self.zynseq.libseq.togglePlayState(self.zynseq.bank, pad) + midi_chan = self.get_filtered_midi_chan_by_index(col) + if midi_chan is not None: + phrase = row + self.scroll_v + try: + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, midi_chan) + except: + pass return True - # CC => scene change + # CC => arrows & phrases elif evtype == 0xB: ccnum = ev[1] & 0x7F ccval = ev[2] & 0x7F if ccval > 0: if ccnum == 91: - self.zyngui.cuia_arrow_up() + self.state_manager.send_cuia("ARROW_UP") elif ccnum == 92: - self.zyngui.cuia_arrow_down() + self.state_manager.send_cuia("ARROW_DOWN") elif ccnum == 93: - self.zyngui.cuia_arrow_left() + self.state_manager.send_cuia("ARROW_LEFT") elif ccnum == 94: - self.zyngui.cuia_arrow_right() + self.state_manager.send_cuia("ARROW_RIGHT") else: col, row = self.get_note_xy(ccnum) if col == 8: - self.zynseq.set_bank(row + 1) + try: + phrase = row + self.scroll_v + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, zynseq.PHRASE_CHANNEL) + except: + pass return True # ------------------------------------------------------------------------------ diff --git a/zyngine/ctrldev/zynthian_ctrldev_launchpad_pro_mk3.py b/zyngine/ctrldev/zynthian_ctrldev_launchpad_pro_mk3.py index 93458c9a6..e4b97e5c4 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_launchpad_pro_mk3.py +++ b/zyngine/ctrldev/zynthian_ctrldev_launchpad_pro_mk3.py @@ -31,6 +31,7 @@ # Zynthian specific modules from zynlibs.zynseq import zynseq +from zyngui import zynthian_gui_config from zyncoder.zyncore import lib_zyncore from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynpad @@ -43,13 +44,6 @@ class zynthian_ctrldev_launchpad_pro_mk3(zynthian_ctrldev_zynpad): dev_ids = ["Launchpad Pro MK3 IN 3"] - PAD_COLOURS = [6, 29, 17, 49, 66, 41, 23, - 13, 96, 2, 81, 82, 83, 84, 85, 86, 87] - STARTING_COLOUR = 21 - STOPPING_COLOUR = 5 - SELECTED_BANK_COLOUR = 29 - STOP_ALL_COLOUR = 5 - def send_sysex(self, data): if self.idev_out is not None: msg = bytes.fromhex(f"F0 00 20 29 02 0E {data} F7") @@ -75,50 +69,35 @@ def end(self): # Select Keys layout (drums = 0x04, keys = 0x05, user = 0x06, prog = 0x7F) self.send_sysex("00 05 00 00") - def update_seq_bank(self): - if self.idev_out is None: - return - # logging.debug("Updating Launchpad Pro MK3 bank leds") - for row in range(0, 7): - note = 89 - 10 * row - if row == self.zynseq.bank - 1: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, 0, note, self.SELECTED_BANK_COLOUR) - else: - lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, note, 0) - # Stop All button => Solid Red - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, 0, 19, self.STOP_ALL_COLOUR) - - def update_seq_state(self, bank, seq, state, mode, group): - if self.idev_out is None or bank != self.zynseq.bank: - return - # logging.debug(f"Updating Launchpad Pro MK3 bank {bank} pad {seq} => state {state}, mode {mode}") - col, row = self.zynseq.get_xy_from_pad(seq) + def update_pad(self, row, col, pad_info): + chan = 0 + vel = 0 note = 10 * (8 - row) + col + 1 try: - if mode == 0: - chan = 0 - vel = 0 + state = pad_info["state"] + mode = pad_info["mode"] + repeat = pad_info["repeat"] + if col == self.cols: + group = 0 + else: + group = pad_info["group"] + if repeat == 0 or mode == 0 or group >= MAX_NUM_MIDI_CHANS: + pass elif state == zynseq.SEQ_STOPPED: chan = 0 - vel = self.PAD_COLOURS[group] + vel = zynthian_gui_config.LAUNCHER_COLOUR[group]["launchpad"] elif state == zynseq.SEQ_PLAYING: chan = 2 - vel = self.PAD_COLOURS[group] + vel = zynthian_gui_config.LAUNCHER_COLOUR[group]["launchpad"] elif state == zynseq.SEQ_STOPPING: chan = 1 - vel = self.STOPPING_COLOUR + vel = zynthian_gui_config.LAUNCHER_STOPPING_COLOUR["launchpad"] elif state == zynseq.SEQ_STARTING: chan = 1 - vel = self.STARTING_COLOUR - else: - chan = 0 - vel = 0 + vel = zynthian_gui_config.LAUNCHER_STARTING_COLOUR["launchpad"] except: - chan = 0 - vel = 0 - # logging.debug("Lighting PAD {}, group {} => {}, {}, {}".format(seq, group, chan, note, vel)) + pass + lib_zyncore.dev_send_note_on(self.idev_out, chan, note, vel) # Light-Off the pad specified with column & row @@ -135,11 +114,15 @@ def midi_event(self, ev): vel = ev[2] & 0x7F if vel > 0: col, row = self.get_note_xy(note) - pad = self.zynseq.get_pad_from_xy(col, row) - if pad >= 0: - self.zynseq.libseq.togglePlayState(self.zynseq.bank, pad) + midi_chan = self.get_filtered_midi_chan_by_index(col) + if midi_chan is not None: + phrase = row + self.scroll_v + try: + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, midi_chan) + except: + pass return True - # CC => arrows, scene change, stop all + # CC => arrows & phrases elif evtype == 0xB: ccnum = ev[1] & 0x7F ccval = ev[2] & 0x7F @@ -152,14 +135,14 @@ def midi_event(self, ev): self.state_manager.send_cuia("ARROW_LEFT") elif ccnum == 0x5C: self.state_manager.send_cuia("ARROW_RIGHT") - else: col, row = self.get_note_xy(ccnum) if col == 8: - if row < 7: - self.zynseq.select_bank(row + 1) - elif row == 7: - self.zynseq.libseq.stop() + try: + phrase = row + self.scroll_v + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, zynseq.PHRASE_CHANNEL) + except: + pass return True # SysEx elif ev[0] == 0xF0: diff --git a/zyngine/ctrldev/zynthian_ctrldev_launchpad_x.py b/zyngine/ctrldev/zynthian_ctrldev_launchpad_x.py index 3b9e0f74e..523e51aa7 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_launchpad_x.py +++ b/zyngine/ctrldev/zynthian_ctrldev_launchpad_x.py @@ -5,9 +5,8 @@ # # Zynthian Control Device Driver for "Novation Launchpad X" # -# Copyright (C) 2015-2023 Fernando Moyano +# Copyright (C) 2015-2025 Fernando Moyano # Brian Walton -# Wapata # # ****************************************************************************** # @@ -29,9 +28,11 @@ from time import sleep # Zynthian specific modules -from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynpad -from zyncoder.zyncore import lib_zyncore from zynlibs.zynseq import zynseq +from zyncoder.zyncore import lib_zyncore +from zyngine.zynthian_chain_manager import MAX_NUM_MIDI_CHANS +from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynpad +from zyngui import zynthian_gui_config # ------------------------------------------------------------------------------------------------------------------ # Novation Launchpad X @@ -41,15 +42,11 @@ class zynthian_ctrldev_launchpad_x(zynthian_ctrldev_zynpad): dev_ids = ["Launchpad X IN 1"] - - PAD_COLOURS = [6, 29, 17, 49, 66, 41, 23, - 13, 96, 2, 81, 82, 83, 84, 85, 86, 87] - STARTING_COLOUR = 21 - STOPPING_COLOUR = 5 + driver_description = "Launcher + arrow keys integration" def send_sysex(self, data): if self.idev_out is not None: - msg = bytes.fromhex("F0 00 20 29 02 0C {} F7".format(data)) + msg = bytes.fromhex(f"F0 00 20 29 02 0C {data} F7") lib_zyncore.dev_send_midi_event(self.idev_out, msg, len(msg)) sleep(0.05) @@ -61,70 +58,67 @@ def get_note_xy(self, note): def init(self): # Awake self.sleep_off() + # self.send_sysex_universal_inquiry() # Enter DAW session mode self.send_sysex("10 01") # Select session layout (session = 0x00, faders = 0x0D) self.send_sysex("00 00") - # Light off - # self.light_off() + super().init() def end(self): - # Light off - self.light_off() + super().end() # Exit DAW session mode self.send_sysex("10 00") # Select Keys layout (drums = 0x04, keys = 0x05, user = 0x06, prog = 0x7F) self.send_sysex("00 05") - # Zynpad Scene LED feedback - def refresh_zynpad_bank(self): - if self.idev_out is None: - return - # logging.debug("Updating Launchpad X bank leds") - for row in range(0, 8): - note = 89 - 10 * row - if row == self.zynseq.bank - 1: - lib_zyncore.dev_send_ccontrol_change( - self.idev_out, 0, note, 29) - else: - lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, note, 0) - - # Zynpad Pad LED feedback - def update_pad(self, pad, state, mode): - if self.idev_out is None: - return - # logging.debug("Updating Launchpad X pad {}".format(pad)) - col, row = self.zynseq.get_xy_from_pad(pad) - note = 10 * (8 - row) + col + 1 - - group = self.zynseq.libseq.getGroup(self.zynseq.bank, pad) + def update_pad(self, row, col, pad_info): + midi_chan = 0 + color = 0 try: - if mode == 0: - chan = 0 - vel = 0 + state = pad_info["state"] + if col == self.cols: + group = 32 + else: + group = pad_info["group"] + if pad_info["repeat"] == 0 or group > MAX_NUM_MIDI_CHANS: + pass elif state == zynseq.SEQ_STOPPED: - chan = 0 - vel = self.PAD_COLOURS[group] - elif state == zynseq.SEQ_PLAYING: - chan = 2 - vel = self.PAD_COLOURS[group] - elif state == zynseq.SEQ_STOPPING: - chan = 1 - vel = self.STOPPING_COLOUR + if not pad_info["empty"]: + color = zynthian_gui_config.LAUNCHER_COLOUR[group]["launchpad"] + elif state in (zynseq.SEQ_PLAYING, zynseq.SEQ_CHILD_PLAYING): + midi_chan = 2 + if col == self.cols: + color = zynthian_gui_config.LAUNCHER_STARTING_COLOUR["launchpad"] + else: + color = zynthian_gui_config.LAUNCHER_COLOUR[group]["launchpad"] + elif state in (zynseq.SEQ_STOPPING, zynseq.SEQ_STOPPING_SYNC, zynseq.SEQ_FORCED_STOP, zynseq.SEQ_CHILD_STOPPING): + midi_chan = 1 + color = zynthian_gui_config.LAUNCHER_STOPPING_COLOUR["launchpad"] elif state == zynseq.SEQ_STARTING: - chan = 1 - vel = self.STARTING_COLOUR - else: - chan = 0 - vel = 0 + midi_chan = 1 + color = zynthian_gui_config.LAUNCHER_STARTING_COLOUR["launchpad"] except: - chan = 0 - vel = 0 - # logging.debug("Lighting PAD {}, group {} => {}, {}, {}".format(pad, group, chan, note, vel)) - lib_zyncore.dev_send_note_on(self.idev_out, chan, note, vel) + pass + # Send MIDI event to controller + if col < self.cols: + note = 10 * (8 - row) + col + 1 + lib_zyncore.dev_send_note_on(self.idev_out, midi_chan, note, color) + elif col == self.cols: + ccnum = 89 - 10 * row + lib_zyncore.dev_send_ccontrol_change(self.idev_out, midi_chan, ccnum, color) + + # Light-Off the pad specified with chan & phrase (column & row) + def pad_off(self, col, row): + if col < zynseq.PHRASE_CHANNEL: + note = 10 * (8 - row) + col + 1 + lib_zyncore.dev_send_note_on(self.idev_out, 0, note, 0) + elif col == zynseq.PHRASE_CHANNEL: + ccnum = 89 - 10 * row + lib_zyncore.dev_send_ccontrol_change(self.idev_out, 0, ccnum, 0) def midi_event(self, ev): - # logging.debug("Launchpad X MIDI handler => {}".format(ev)) + # logging.debug(f"Launchpad X MIDI handler => {ev}") evtype = (ev[0] >> 4) & 0x0F # Note ON => launch/stop sequence if evtype == 0x9: @@ -132,27 +126,39 @@ def midi_event(self, ev): vel = ev[2] & 0x7F if vel > 0: col, row = self.get_note_xy(note) - pad = self.zynseq.get_pad_from_xy(col, row) - if pad >= 0: - self.zynseq.libseq.togglePlayState(self.zynseq.bank, pad) + midi_chan = self.get_filtered_midi_chan_by_index(col) + if midi_chan is not None: + phrase = row + self.scroll_v + try: + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, midi_chan) + except: + pass return True - # CC => scene change + # CC => arrows & phrases elif evtype == 0xB: ccnum = ev[1] & 0x7F ccval = ev[2] & 0x7F if ccval > 0: if ccnum == 0x5B: - self.zyngui.cuia_arrow_up() + self.state_manager.send_cuia("ARROW_UP") elif ccnum == 0x5C: - self.zyngui.cuia_arrow_down() + self.state_manager.send_cuia("ARROW_DOWN") elif ccnum == 0x5D: - self.zyngui.cuia_arrow_left() + self.state_manager.send_cuia("ARROW_LEFT") elif ccnum == 0x5E: - self.zyngui.cuia_arrow_right() + self.state_manager.send_cuia("ARROW_RIGHT") else: col, row = self.get_note_xy(ccnum) if col == 8: - self.zynseq.set_bank(row + 1) + try: + phrase = row + self.scroll_v + self.zynseq.libseq.togglePlayState(self.zynseq.scene, phrase, zynseq.PHRASE_CHANNEL) + except: + pass + return True + # SysEx + elif ev[0] == 0xF0: + logging.info(f"Received SysEx => {ev.hex(' ')}") return True # Light-Off LEDs diff --git a/zyngine/ctrldev/zynthian_ctrldev_mackiecontrol.py b/zyngine/ctrldev/zynthian_ctrldev_mackiecontrol.py index c8d3de373..bcd24c962 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_mackiecontrol.py +++ b/zyngine/ctrldev/zynthian_ctrldev_mackiecontrol.py @@ -55,7 +55,6 @@ def __init__(self, state_manager, idev_in, idev_out=None): self.sysex_answer_cb = None self.midi_chan = 0x0 # zero is the default don't change - self.rec_mode = 0 self.shift = False self.my_settings = self.load_yaml_config(self.mackie_config_path, self.mackie_config_file) @@ -121,10 +120,9 @@ def __init__(self, state_manager, idev_in, idev_out=None): else: self.fader_touch_active = [True, True, True, True, True, True, True, True, True] self.max_fader_value = 16383.0 # I think this is default Mackie - self.first_zyn_channel_fader = 0 # To be able to scroll around the channels self.encoder_assign = 'global_view' # Set as default self.strip_view = 'global_view' # Set default - self.gui_screen = 'audio_mixer' # Set as default, it's needed to correct an issue when starting up + self.gui_screen = 'mixer' # Set as default, it's needed to correct an issue when starting up # TODO: add to yaml file self.encoders_ccnum = [16, 17, 18, 19, 20, 21, 22, 23] self.scroll_encoder = 60 @@ -181,6 +179,15 @@ def update_lcd_text(self, pos, text): data.append(hex) self.send_syx(data=' '.join(data)) + def update_all_lcd_text(self, text1, text2): + data = ['12', '00'] + text = f"{text1: <56}{text2: <56}" + for num in range(56): + letter = text[num] + hex = letter.encode('utf-8').hex() + data.append(hex) + self.send_syx(data=' '.join(data)) + def update_top_lcd_text(self, channel, top_text=''): pos_top = ['00', '07', '0e', '15', '1c', '23', '2a', '31'] self.update_lcd_text(pos_top[channel], top_text) @@ -200,13 +207,6 @@ def update_bottom_lcd_text(self, channel, bottom_text=''): pos_bottom = ['38', '3f', '46', '4d', '54', '5b', '62', '69'] self.update_lcd_text(pos_bottom[channel], bottom_text) - def get_master_chain_audio_channel(self): - master_chain = self.chain_manager.get_chain(0) - if master_chain is not None: - return master_chain.mixer_chan - else: - return 255 - # mkc Functions def buttonled_on(self, ccnum): lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, ccnum, 127) @@ -216,57 +216,32 @@ def buttonled_off(self, ccnum): def rec(self, id, ccnum, ccval): if ccval == 127: - col = int(id) + self.first_zyn_channel_fader - if col < len(self.get_ordered_chain_ids_filtered()): - chain = self.get_chain_by_position(col) - mixer_chan = chain.mixer_chan - if mixer_chan is not None: - self.state_manager.audio_recorder.toggle_arm(mixer_chan) - # Send LED feedback - if self.idev_out is not None: - val = self.state_manager.audio_recorder.is_armed(mixer_chan) * 0x7F - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, ccnum, val) + col = int(id) + self.scroll_h + val = self.toggle_mixer_param("record", col) + # Send LED feedback + if self.idev_out is not None: + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, ccnum, val * 0x7F) def solo(self, id, ccnum, ccval): if ccval == 127: - col = int(id) + self.first_zyn_channel_fader - if col < len(self.get_ordered_chain_ids_filtered()): - chain = self.get_chain_by_position(col) - mixer_chan = chain.mixer_chan - if mixer_chan is not None: - if self.zynmixer.get_solo(mixer_chan): - val = 0 - else: - val = 1 - self.zynmixer.set_solo(mixer_chan, val, True) - if self.idev_out is not None: - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, ccnum, val * 0x7F) - elif self.idev_out is not None: - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, ccnum, 0) + col = int(id) + self.scroll_h + val = self.toggle_mixer_param("solo", col) + # Send LED feedback + if self.idev_out is not None: + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, ccnum, val * 0x7F) def mute(self, id, ccnum, ccval): if ccval == 127: - col = int(id) + self.first_zyn_channel_fader - if col < len(self.get_ordered_chain_ids_filtered()): - chain = self.get_chain_by_position(col) - mixer_chan = chain.mixer_chan - if mixer_chan is not None: - if self.zynmixer.get_mute(mixer_chan): - val = 0 - else: - val = 1 - self.zynmixer.set_mute(mixer_chan, val, True) - if self.idev_out is not None: - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, ccnum, val * 0x7F) - elif self.idev_out is not None: - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, ccnum, 0) + col = int(id) + self.scroll_h + val = self.toggle_mixer_param("mute", col) + # Send LED feedback + if self.idev_out is not None: + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, ccnum, val * 0x7F) def select(self, id, ccnum, ccval): if ccval == 127: - col = int(id) + self.first_zyn_channel_fader - if col < len(self.get_ordered_chain_ids_filtered()): - chain = self.get_chain_by_position(col) - self.chain_manager.set_active_chain_by_id(chain_id=chain.chain_id) + col = int(id) + self.scroll_h + self.chain_manager.set_active_chain_by_index(col) def encoderpress(self, id, ccnum, ccval): if self.encoder_assign == 'global_view': @@ -279,6 +254,7 @@ def encoderpress(self, id, ccnum, ccval): def globalview(self, id, ccnum, ccval): self.strip_view = 'global_view' + self.chain_type_filter = [] self.refresh() def encoderassign(self, id, ccnum, ccval): @@ -291,33 +267,53 @@ def encoderassign(self, id, ccnum, ccval): def viewassign(self, id, ccnum, ccval): self.strip_view = id + match self.strip_view: + case "audio": + self.chain_type_filter = ["generator"] + case "midi": + self.chain_type_filter = ["midi"] + case "inst": + self.chain_type_filter = ["synth"] + case "inputs": + self.chain_type_filter = ["audio_in"] + case "outputs": + self.chain_type_filter = ["audio_out"] + case "aux": + self.chain_type_filter = ["mixbus"] + case "buses": + self.chain_type_filter = ["mixbus"] + case "user": + pass + case _: + self.chain_type_filter = [] self.refresh() def faderbank(self, direction, ccnum, ccval): if ccval == 127: + n_strips = self.device_settings['number_of_strips'] if direction == 'left': - if self.first_zyn_channel_fader > 0: - self.first_zyn_channel_fader -= self.device_settings['number_of_strips'] - if self.first_zyn_channel_fader < 0: - self.first_zyn_channel_fader = 0 + if self.scroll_h > 0: + self.scroll_h -= n_strips + if self.scroll_h < 0: + self.scroll_h = 0 self.refresh() elif direction == 'right': - for n in range(1, int(len(self.get_ordered_chain_ids_filtered()) / self.device_settings[ - 'number_of_strips'] + 1)): - if self.first_zyn_channel_fader < self.device_settings['number_of_strips'] * n: - self.first_zyn_channel_fader = self.device_settings['number_of_strips'] * n - self.refresh() + n_chains = len(self.chain_manager.chains) + if self.scroll_h < n_chains - n_strips: + self.scroll_h += n_strips + self.refresh() def channel(self, direction, ccnum, ccval): if ccval == 127: + n_strips = self.device_settings['number_of_strips'] if direction == 'left': - if self.first_zyn_channel_fader > 0: - self.first_zyn_channel_fader -= 1 + if self.scroll_h > 0: + self.scroll_h -= 1 self.refresh() elif direction == 'right': - if self.first_zyn_channel_fader < len(self.get_ordered_chain_ids_filtered()) - self.device_settings[ - 'number_of_strips']: - self.first_zyn_channel_fader += 1 + n_chains = len(self.chain_manager.chains) + if self.scroll_h < n_chains - n_strips: + self.scroll_h += 1 self.refresh() def transport(self, command, ccnum, ccval): @@ -343,52 +339,21 @@ def transport(self, command, ccnum, ccval): def shiftassign(self, id, ccnum, ccval): if ccval == 127: self.shift = not self.shift - self.rec_mode = self.shift self.refresh() + def display(self, id, ccnum, ccval): + pass + def fadertouch(self, id, ccnum, ccval): + #logging.debug(f"FADERTOUCH => ID {id}, {ccnum}, {ccval}") if ccval == 127: self.fader_touch_active[int(id)] = True - #lib_zyncore.dev_send_note_on(self.idev_out, 0, ccnum, 64) - elif ccval == 64: - self.fader_touch_active[int(id)] = False - #lib_zyncore.dev_send_note_on(self.idev_out, 0, ccnum, 127) - - def get_ordered_chain_ids_filtered(self): - chain_ids = list(self.chain_manager.ordered_chain_ids) - if self.device_settings['masterfader']: - try: - chain_ids.pop() - except: - pass - if self.strip_view == 'global_view': - return chain_ids - ordered_chain_ids_filtered = [] - for chain_id in chain_ids: - chain = self.chain_manager.chains[chain_id] - if self.strip_view == 'midi' and chain.is_midi() and not chain.is_synth(): - ordered_chain_ids_filtered.append(chain_id) - elif self.strip_view == 'audio' and chain.is_audio() and not chain.is_synth(): - ordered_chain_ids_filtered.append(chain_id) - elif self.strip_view == 'inst' and chain.is_synth(): - ordered_chain_ids_filtered.append(chain_id) - return ordered_chain_ids_filtered - - def get_chain_by_position(self, pos): - ordered_chain_ids_filtered = self.get_ordered_chain_ids_filtered() - if pos < len(ordered_chain_ids_filtered): - return self.chain_manager.chains[ordered_chain_ids_filtered[pos]] else: - return None - - def get_mixer_chan_from_device_col(self, col): - chain = self.get_chain_by_position(col) - if chain is not None: - if chain.is_audio() or chain.synth_slots: - return chain.mixer_chan - return None + self.fader_touch_active[int(id)] = False def init_fader_touch(self): + if self.idev_out is None: + return for ccnum in self.faderstouch_ccnum: lib_zyncore.dev_send_note_on(self.idev_out, 0, ccnum, 127) @@ -402,6 +367,7 @@ def init(self): zynsigman.register_queued(zynsigman.S_STATE_MAN, self.state_manager.SS_MIDI_RECORDER_STATE, self.refresh_midi_transport) super().init() self.init_fader_touch() + self.update_all_lcd_text("Zynthian CTRLDEV driver for Mackie Control", "Enjoy and play the waves") def end(self): super().end() @@ -475,43 +441,39 @@ def get_lcd_bottom_text(self, channel, chain): return bottom_text # Update LED and Fader status for a single strip - def update_mixer_strip(self, chan, symbol, value): - logging.debug(f"update_mixer_strip made chan: {chan} symbol: {symbol} value: {value} ") + def update_mixer_strip(self, chan, symbol, value, mixbus=False): if self.idev_out is None: return - - chain_id = self.chain_manager.get_chain_id_by_mixer_chan(chan) - logging.debug(f'chain_id: {chain_id}') - + chain_id = self.chain_manager.get_chain_id_by_mixer_chan(chan, mixbus) + #logging.debug(f"update_mixer_strip chan: {chan} symbol: {symbol} value: {value}, mixbus: {mixbus} => chain ID: {chain_id}") if chain_id is not None: # Master Strip Level - if chain_id == 0 and symbol == "level" and self.device_settings['masterfader']: - if not self.fader_touch_active[self.device_settings['masterfader_fader_num']]: + if chain_id == 0 and self.device_settings['masterfader']: + if symbol == "level" and not self.fader_touch_active[self.device_settings['masterfader_fader_num']]: lib_zyncore.dev_send_pitchbend_change(self.idev_out, self.device_settings['masterfader_fader_num'], int(value * self.max_fader_value)) return else: - if not (chain_id == 0 and self.device_settings['masterfader']): - logging.debug(f'get_ordered_chain_ids_filtered: {self.get_ordered_chain_ids_filtered()}') - col = self.get_ordered_chain_ids_filtered().index(chain_id) - col -= self.first_zyn_channel_fader - if 0 <= col < self.device_settings['number_of_strips']: - logging.debug(f'update_mixer_strip chain_id: {chain_id}') - if symbol == "mute": - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.mute_ccnums[col], value * 0x7F) - elif symbol == "solo": - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.solo_ccnums[col], value * 0x7F) - elif symbol == "rec": - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.rec_ccnums[col], value * 0x7F) - elif symbol == "balance": - if self.encoder_assign == "pan": - self.update_bottom_lcd_text(col, f'{int(value * 100)}%') - elif symbol == "level": - if not self.fader_touch_active[col]: - lib_zyncore.dev_send_pitchbend_change(self.idev_out, col, int(value * self.max_fader_value)) + if mixbus: # TODO: Implement mixbuses!! + return + col = self.get_filtered_index_by_chain_id(chain_id) - self.scroll_h + if 0 <= col < self.device_settings['number_of_strips']: + if symbol == "mute": + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.mute_ccnums[col], value * 0x7F) + elif symbol == "solo": + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.solo_ccnums[col], value * 0x7F) + elif symbol == "rec": + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.rec_ccnums[col], value * 0x7F) + elif symbol == "balance": + if self.encoder_assign == "pan": + self.update_bottom_lcd_text(col, f'{int(value * 100)}%') + elif symbol == "level": + if not self.fader_touch_active[col]: + lib_zyncore.dev_send_pitchbend_change(self.idev_out, col, int(value * self.max_fader_value)) # Update LED status for active chain def update_mixer_active_chain(self, active_chain): - logging.debug(f'update_mixer_active_chain: {active_chain}') + if self.idev_out is None: + return if active_chain == 0: left_led, right_led = [77 - 48, 77 - 48] else: @@ -521,21 +483,18 @@ def update_mixer_active_chain(self, active_chain): # Set correct select led, if within the mixer range for i in range(0, self.device_settings['number_of_strips']): - sel = 0 - try: - ordered_chain_ids_filtered = self.get_ordered_chain_ids_filtered() - chain_id = ordered_chain_ids_filtered[i + self.first_zyn_channel_fader] - if chain_id == active_chain: - sel = 0x7F - if active_chain == 0 and self.device_settings['masterfader']: - sel = 0 - except: + chain_id = self.get_filtered_chain_id_by_index(self.scroll_h + i) + if chain_id == active_chain: + sel = 0x7F + if chain_id == 0 and self.device_settings['masterfader']: + sel = 0 + else: sel = 0 lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.select_ccnums[i], sel) # Update full LED, Faders and Display status def refresh(self): - logging.debug(f"~~~ refresh ~~~") + super().refresh() if self.idev_out is None: return @@ -563,70 +522,53 @@ def refresh(self): # Master Channel Strip if self.device_settings['masterfader']: - master_chain = self.chain_manager.get_chain(0) - if master_chain is not None: - zyn_volume_main = self.zynmixer.get_level(master_chain.mixer_chan) # The Master Chain doesn't have a mixer_chan defined - logging.debug(f'Master Channel Volume Level: {zyn_volume_main}') - lib_zyncore.dev_send_pitchbend_change(self.idev_out, self.device_settings['masterfader_fader_num'], int(zyn_volume_main * self.max_fader_value)) + val = self.get_mixer_param("level", -1) + lib_zyncore.dev_send_pitchbend_change(self.idev_out, self.device_settings['masterfader_fader_num'], int(val * self.max_fader_value)) # Strips Leds, Faders and Displays - col0 = self.first_zyn_channel_fader self.gernerate_top_lcd_text() if self.shift: - lib_zyncore.dev_send_note_on( - self.idev_out, self.midi_chan, self.shift_ccnum, 127) + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.shift_ccnum, 127) self.refresh_midi_transport() else: - lib_zyncore.dev_send_note_on( - self.idev_out, self.midi_chan, self.shift_ccnum, 0) + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.shift_ccnum, 0) self.refresh_audio_transport() for i in range(0, self.device_settings['number_of_strips']): - rec = 0 - mute = 0 - solo = 0 - sel = 0 - bottom_text = ' ' - zyn_volume = 0 - - chain = self.get_chain_by_position(col0 + i) - - if chain is not None: - if chain.mixer_chan is not None: - mute = self.zynmixer.get_mute(chain.mixer_chan) * 0x7F - solo = self.zynmixer.get_solo(chain.mixer_chan) * 0x7F - - # LEDs - if chain.mixer_chan is not None: - rec = self.state_manager.audio_recorder.is_armed(chain.mixer_chan) * 0x7F - - # Select LED and Left/Right LED Chain Number - if chain == self.chain_manager.get_active_chain(): - sel = 0x7F - if chain.chain_id == 0: - left_led, right_led = [77 - 48, 77 - 48] - else: - left_led, right_led = list(f"{chain.chain_id:02}") - lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, 75, int(left_led) + 48) - lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, 74, int(right_led) + 48) - - # Chain LCD-Displays - top_text = f'CH {i + 1 + self.first_zyn_channel_fader}' - bottom_text = self.get_lcd_bottom_text(i, chain) - - # Chain Volume - if chain.is_audio() or chain.synth_slots: - zyn_volume = self.zynmixer.get_level(chain.mixer_chan) - if zyn_volume == None: - zyn_volume = 0 - - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.mute_ccnums[i], mute) - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.solo_ccnums[i], solo) - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.rec_ccnums[i], rec) - lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.select_ccnums[i], sel) + pos = self.scroll_h + i + + mute = self.get_mixer_param("mute", pos) + solo = self.get_mixer_param("solo", pos) + rec = self.get_mixer_param("record", pos) + volume = self.get_mixer_param("level", pos) + + # Select LED and Left/Right LED Chain Number + chain_id = self.get_filtered_chain_id_by_index(pos) + if chain_id == self.chain_manager.get_active_chain().chain_id: + sel = 1 + if chain_id == 0: + left_led, right_led = [77 - 48, 77 - 48] + else: + left_led, right_led = list(f"{chain_id:02}") + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, 75, int(left_led) + 48) + lib_zyncore.dev_send_ccontrol_change(self.idev_out, self.midi_chan, 74, int(right_led) + 48) + else: + sel = 0 + + # Chain LCD-Displays + top_text = f'CH {pos + 1}' + try: + bottom_text = self.get_lcd_bottom_text(i, self.chain_manager.chains[chain_id]) + except: + bottom_text = ' ' + + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.mute_ccnums[i], mute * 0x7F) + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.solo_ccnums[i], solo * 0x7F) + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.rec_ccnums[i], rec * 0x7F) + lib_zyncore.dev_send_note_on(self.idev_out, self.midi_chan, self.select_ccnums[i], sel * 0x7F) + lib_zyncore.dev_send_pitchbend_change(self.idev_out, i, int(volume * self.max_fader_value)) + self.update_top_lcd_text(i, top_text) self.update_bottom_lcd_text(i, bottom_text) - lib_zyncore.dev_send_pitchbend_change(self.idev_out, i, int(zyn_volume * self.max_fader_value)) - logging.debug(f"~~~ end refresh ~~~") def midi_event(self, ev): evtype = (ev[0] >> 4) & 0x0F @@ -634,70 +576,63 @@ def midi_event(self, ev): # TODO: Faders move to a funtion if evtype == 14: fader_channel = ev[0] - 0xE0 - logging.debug(f'midi_event fader_channel: {fader_channel}') + #logging.debug(f'fader_channel {fader_channel}') if self.fader_touch_active[fader_channel]: - logging.debug(f'{self.fader_touch_active}') mackie_vol_level = (ev[2] * 128 + ev[1]) zyn_vol_level = mackie_vol_level / self.max_fader_value - if fader_channel == self.device_settings['masterfader_fader_num'] and self.device_settings['masterfader']: - self.zynmixer.set_level(self.get_master_chain_audio_channel(), zyn_vol_level) - lib_zyncore.dev_send_pitchbend_change(self.idev_out, fader_channel, mackie_vol_level) + if self.device_settings['masterfader'] and fader_channel == self.device_settings['masterfader_fader_num']: + pos = -1 else: - mixer_chan = self.get_mixer_chan_from_device_col(fader_channel + self.first_zyn_channel_fader) - if mixer_chan is not None: - self.zynmixer.set_level(mixer_chan, zyn_vol_level) - lib_zyncore.dev_send_pitchbend_change(self.idev_out, fader_channel, mackie_vol_level) - + pos = self.scroll_h + fader_channel + self.set_mixer_param("level", pos, zyn_vol_level) + if self.idev_out is not None: + lib_zyncore.dev_send_pitchbend_change(self.idev_out, fader_channel, mackie_vol_level) return True # TODO: Encoders move to function elif evtype == 11: ccnum = ev[1] & 0x7F ccval = ev[2] & 0x7F - logging.debug(f'Got encoders ccnum: {ccnum}, ccval: {ccval}') + #logging.debug(f'Got encoders ccnum: {ccnum}, ccval: {ccval}') if ccnum in self.encoders_ccnum: # Encoders Zynthian 1 to 8 if self.encoder_assign == 'global_view': if ccnum in self.encoders_ccnum[:4]: # first 4 encoders encoder_num = self.encoders_ccnum.index(ccnum) if ccval > 64: # Encoder turned left - for interation in range(ccval - 64): - self.state_manager.send_cuia("ZYNPOT", params=[encoder_num, -1]) - else: # Encoder turned rigth - for interation in range(ccval): - self.state_manager.send_cuia("ZYNPOT", params=[encoder_num, 1]) + ccval = 64 - ccval + else: # Encoder turned rigth + pass + self.state_manager.send_cuia("ZYNPOT", params=[encoder_num, ccval]) return True - # Encoder PAN if self.encoder_assign == 'pan': col = self.encoders_ccnum.index(ccnum) - chain = self.get_chain_by_position(col + self.first_zyn_channel_fader) - mixer_chan = chain.mixer_chan - if mixer_chan is not None: - balance_value = self.zynmixer.get_balance(mixer_chan) - # encoder_num = ccnum - self.encoders_ccnum[0] + self.first_zyn_channel_fader - if ccval > 64: # Encoder turned left - new_balance_value = round(balance_value - (ccval - 64) / 100.0, 2) - if new_balance_value < -1.0: - new_balance_value = -1.0 - else: # Encoder turned right - new_balance_value = balance_value + ccval / 100.0 - if new_balance_value > 1.0: - new_balance_value = 1.0 - self.zynmixer.set_balance(mixer_chan, new_balance_value, True) - self.update_bottom_lcd_text(col, f'{round(new_balance_value * 100, 0)}%') + pos = self.scroll_h + col + balance_value = self.get_mixer_param("balance", pos) + # encoder_num = ccnum - self.encoders_ccnum[0] + self.scroll_h + if ccval > 64: # Encoder turned left + new_balance_value = round(balance_value - (ccval - 64) / 100.0, 2) + if new_balance_value < -1.0: + new_balance_value = -1.0 + else: # Encoder turned right + new_balance_value = round(balance_value + ccval / 100.0, 2) + if new_balance_value > 1.0: + new_balance_value = 1.0 + self.set_mixer_param("balance", col, new_balance_value) + self.update_bottom_lcd_text(col, f'{round(new_balance_value * 100, 0)}%') return True elif ccnum == self.scroll_encoder: if ccval > 64: for i in range(ccval - 64): - if self.gui_screen in ['audio_mixer']: + if self.gui_screen in ['mixer']: self.state_manager.send_cuia("ARROW_LEFT") else: self.state_manager.send_cuia('ARROW_UP') else: for i in range(ccval): - if self.gui_screen in ['audio_mixer']: + if self.gui_screen in ['mixer']: self.state_manager.send_cuia('ARROW_RIGHT') else: self.state_manager.send_cuia('ARROW_DOWN') @@ -707,34 +642,29 @@ def midi_event(self, ev): elif ev[0] != 0xF0: ccnum = ev[1] & 0x7F ccval = ev[2] & 0x7F - logging.debug(f"midid_event - evtype:{evtype} ccnum:{ccnum} ccval:{ccval}") + #logging.debug(f"midid_event - evtype:{evtype} ccnum:{ccnum} ccval:{ccval}") # Catch all the ccnum buttons listed in the yaml file if ccnum in self.my_settings['ccnum_buttons'].keys(): - logging.debug(f'Got ccnum: {ccnum}') event = self.my_settings['ccnum_buttons'][ccnum] - logging.debug(f'got event: {event}') - cmd = event['command'] - logging.debug(f'got command: {cmd}') if self.shift and 'shiftcmd' in event.keys(): cmd = event['shiftcmd'] - + else: + cmd = event['command'] + logging.debug(f'Got ccnum {ccnum}, event {event} => command {cmd}') if cmd.startswith('cuia') and ccval == 127: - logging.debug(f'got cuia command: {cmd}') self.state_manager.send_cuia(cmd.lstrip('cuia_')) return True - elif cmd.startswith('ZYNSWITCH'): if ccval == 127: self.state_manager.send_cuia("ZYNSWITCH", params=[cmd.lstrip('ZYNSWITCH_'), 'P']) else: self.state_manager.send_cuia("ZYNSWITCH", params=[cmd.lstrip('ZYNSWITCH_'), 'R']) return True - elif cmd.startswith('mkc'): - func_and_value = cmd.split('_') - my_method_ref = getattr(zynthian_ctrldev_mackiecontrol, func_and_value[1]) # my function - my_method_ref(self, func_and_value[2], ccnum, ccval) # called with value + parts = cmd.split('_') + my_func = getattr(zynthian_ctrldev_mackiecontrol, parts[1]) # my function + my_func(self, parts[2], ccnum, ccval) # call function with arguments return True # SysEx diff --git a/zyngine/ctrldev/zynthian_ctrldev_riband.py b/zyngine/ctrldev/zynthian_ctrldev_riband.py index 4d2a5a7ff..c422e9bb5 100644 --- a/zyngine/ctrldev/zynthian_ctrldev_riband.py +++ b/zyngine/ctrldev/zynthian_ctrldev_riband.py @@ -5,7 +5,7 @@ # # Zynthian Control Device Driver for "riband wearable controller" # -# Copyright (C) 2015-2024 Fernando Moyano +# Copyright (C) 2015-2025 Fernando Moyano # Brian Walton # # ****************************************************************************** @@ -27,7 +27,7 @@ import logging # Zynthian specific modules -from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynpad, zynthian_ctrldev_zynmixer +from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynpad from zyncoder.zyncore import lib_zyncore from zynlibs.zynseq import zynseq @@ -51,23 +51,28 @@ def end(self): lib_zyncore.dev_send_note_on(self.idev_out, 0, note, 0) super().end() - def update_seq_state(self, bank, seq, state, mode, group): - if self.idev_out is None or bank != self.zynseq.bank: + def update_seq_state(self, phrase, chan, state, mode): + if self.idev_out is None: return - col, row = self.zynseq.get_xy_from_pad(seq) - if row > 3 or col > 3: + try: + #TODO: FIXME - Can we use default function from parent? + chain_id = self._chain_manager.get_chain_ids_by_midi_chan(chan)[0] + col = self._chain_manager.get_chain_index(chain_id) + except: return - note = col * 4 + row + if phrase > 3 or col > 3: + return + note = col * 4 + phrase if note > 15: return try: - if mode == 0 or group > 25: + if mode == 0 or chan > 15: vel = 0 elif state == zynseq.SEQ_STOPPED: - vel = 4 + group + vel = 4 + chan elif state == zynseq.SEQ_PLAYING: - vel = 64 + group - elif state in [zynseq.SEQ_STOPPING, zynseq.SEQ_STOPPINGSYNC]: + vel = 64 + chan + elif state in [zynseq.SEQ_STOPPING, zynseq.SEQ_STOPPING_SYNC]: vel = 33 elif state == zynseq.SEQ_STARTING: vel = 31 @@ -88,7 +93,7 @@ def midi_event(self, ev): if evtype == 0x9: note = ev[1] & 0x7F vel = ev[2] & 0x7F - if vel > 0 and note < self.zynseq.seq_in_bank: + if vel > 0 and note < self.zynseq.seq_in_scene: # Toggle pad self.zynseq.libseq.togglePlayState(self.zynseq.bank, note) return True diff --git a/zyngine/ctrldev/zynthian_ctrldev_teenageengineering_op1.py b/zyngine/ctrldev/zynthian_ctrldev_teenageengineering_op1.py new file mode 100644 index 000000000..f6424ca74 --- /dev/null +++ b/zyngine/ctrldev/zynthian_ctrldev_teenageengineering_op1.py @@ -0,0 +1,173 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian Control Device Driver +# +# Zynthian Control Device Driver for Teenage Engineering OP-1 OG (Original) +# Tested with firmware version 246. +# Designed to work with OP-1 in MIDI Mode: +# - On OP-1, press Shift+COM +# - Choose CTRL (Button T2) +# - Press Shift button to verify knobs are sending relative CC, change with blue +# encoder if needed. +# - Press Shift button to verify <> buttons are sending MIDI CC, change +# with white encoder if desired. Set to Octave +- if this behavior is +# preferred. +# +# Copyright (C) 2026 Bernard Vander Beken +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import logging + +# Zynthian specific modules +from zynlibs.zynseq import zynseq +from zyncoder.zyncore import lib_zyncore + +from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_base +from zyngine.ctrldev.zynthian_ctrldev_base import zynthian_ctrldev_zynmixer + +# ------------------------------------------------------------------------------------------------------------------ +# MIDI CC messages sent by OP-1 +# NOTE: constants based on https://github.com/pcppcp/op1/blob/live-9/op1/consts.py +# ------------------------------------------------------------------------------------------------------------------ +OP1_ENCODER_1 = 1 +OP1_ENCODER_2 = 2 +OP1_ENCODER_3 = 3 +OP1_ENCODER_4 = 4 + +OP1_ENCODER_1_PUSH = 64 +OP1_ENCODER_2_PUSH = 65 +OP1_ENCODER_3_PUSH = 66 +OP1_ENCODER_4_PUSH = 67 + +OP1_HELP_BUTTON = 5 +OP1_METRONOME_BUTTON = 6 + +OP1_MODE_1_BUTTON = 7 +OP1_MODE_2_BUTTON = 8 +OP1_MODE_3_BUTTON = 9 +OP1_MODE_4_BUTTON = 10 + +OP1_T1_BUTTON = 11 +OP1_T2_BUTTON = 12 +OP1_T3_BUTTON = 13 +OP1_T4_BUTTON = 14 + +OP1_ARROW_UP_BUTTON = 15 +OP1_ARROW_DOWN_BUTTON = 16 +OP1_SCISSOR_BUTTON = 17 + +OP1_SS1_BUTTON = 50 +OP1_SS2_BUTTON = 51 +OP1_SS3_BUTTON = 52 +OP1_SS4_BUTTON = 21 +OP1_SS5_BUTTON = 22 +OP1_SS6_BUTTON = 23 +OP1_SS7_BUTTON = 24 +OP1_SS8_BUTTON = 25 + +OP1_SEQ_BUTTON = 26 + +OP1_REC_BUTTON = 38 +OP1_PLAY_BUTTON = 39 +OP1_STOP_BUTTON = 40 + +OP1_LEFT_ARROW = 41 +OP1_RIGHT_ARROW = 42 +OP1_SHIFT_BUTTON = 43 + +OP1_MICRO = 48 +OP1_COM = 49 +# ------------------------------------------------------------------------------------------------------------------ + +class zynthian_ctrldev_teenageengineering_op1(zynthian_ctrldev_zynmixer): + + dev_ids = ["OP-1 IN 1"] + driver_name = "OP-1" + driver_description = "Teenage Engineering OP-1 integration" + unroute_from_chains = False + + def __init__(self, state_manager, idev_in, idev_out): + super().__init__(state_manager, idev_in, idev_out) + + def midi_event(self, ev): + evtype = (ev[0] >> 4) & 0x0F + channel = ev[0] & 0x0F + ccnum = ev[1] & 0x7F + ccval = ev[2] & 0x7F + + # Control Change (CC) message + if evtype == 0xB: + # Encoder rotate: relative CC -127 is CW, 1 is CCW + # Corresponds to relative mode 2 (or inverse of it) in zynthian_controller.midi_cc_mode_detect() + if ccnum >= OP1_ENCODER_1 and ccnum <= OP1_ENCODER_4: + + zynpot = ccnum - 1 # 0 based instead of 1 based + delta = ccval if ccval < 64 else (ccval - 128) + + # logging.debug(f"ZYNPOT {zynpot}, {delta}") + + self.state_manager.send_cuia("ZYNPOT", (zynpot, delta)) + + # Encoder buttons + # ZynSwitch logic for button presses and releases + elif ccnum >= OP1_ENCODER_1_PUSH and ccnum <= OP1_ENCODER_4_PUSH: + # CC to corresonding zynswitch mappings + zynswitch_index = {OP1_ENCODER_1_PUSH: 0, OP1_ENCODER_2_PUSH: 1, OP1_ENCODER_3_PUSH: 2, OP1_ENCODER_4_PUSH: 3}.get(ccnum) + + # Push button + if ccval == 127: + self.state_manager.send_cuia("ZYNSWITCH", (zynswitch_index, "P")) + # Release button + else: + self.state_manager.send_cuia("ZYNSWITCH", (zynswitch_index, "R")) + + # Mix / Level + elif ccnum == OP1_MODE_4_BUTTON: + if ccval > 0: + # Corresponds to Mix / Level on V5 + self.state_manager.send_cuia("ZYNSWITCH", (5, "S")) + + # Tempo + elif ccnum == OP1_METRONOME_BUTTON: + if ccval > 0: + self.state_manager.send_cuia("TEMPO") + + # Arrows + elif ccnum == OP1_ARROW_UP_BUTTON: + if ccval > 0: + self.state_manager.send_cuia("ARROW_UP") + elif ccnum == OP1_ARROW_DOWN_BUTTON: + if ccval > 0: + self.state_manager.send_cuia("ARROW_DOWN") + elif ccnum == OP1_LEFT_ARROW: + if ccval > 0: + self.state_manager.send_cuia("ARROW_LEFT") + elif ccnum == OP1_RIGHT_ARROW: + if ccval > 0: + self.state_manager.send_cuia("ARROW_RIGHT") + + # Record, playback + elif ccnum == OP1_REC_BUTTON: + if ccval > 0: + self.state_manager.send_cuia("TOGGLE_AUDIO_RECORD") + elif ccnum == OP1_PLAY_BUTTON: + if ccval > 0: + self.state_manager.send_cuia("TOGGLE_AUDIO_PLAY") + +# ------------------------------------------------------------------------------ \ No newline at end of file diff --git a/zyngine/zynthian_audio_recorder.py b/zyngine/zynthian_audio_recorder.py index 353931a0d..80019ba12 100644 --- a/zyngine/zynthian_audio_recorder.py +++ b/zyngine/zynthian_audio_recorder.py @@ -27,9 +27,11 @@ import os import logging from subprocess import Popen +from datetime import datetime # Zynthian specific modules from zyngine.zynthian_signal_manager import zynsigman +from zyngui import zynthian_gui_config # ------------------------------------------------------------------------------ # Zynthian Audio Recorder Class @@ -48,29 +50,21 @@ class zynthian_audio_recorder: def __init__(self, state_manager): self.rec_proc = None self.status = False - self.armed = set() # List of chains armed to record self.state_manager = state_manager self.filename = None - def arm(self, channel): - self.armed.add(channel) - zynsigman.send(zynsigman.S_AUDIO_RECORDER, self.SS_AUDIO_RECORDER_ARM, chan=channel, value=True) - - def unarm(self, channel): - try: - self.armed.remove(channel) - zynsigman.send(zynsigman.S_AUDIO_RECORDER, self.SS_AUDIO_RECORDER_ARM, chan=channel, value=False) - except: - logging.info("Channel %d not armed", channel) - - def toggle_arm(self, channel): - if self.is_armed(channel): - self.unarm(channel) + def get_new_filename(self): + exdirs = zynthian_gui_config.get_external_storage_dirs(self.ex_data_dir) + if exdirs: + path = exdirs[0] + filename = datetime.now().strftime("%Y-%m-%d_%H%M%S") else: - self.arm(channel) - - def is_armed(self, channel): - return channel in self.armed + path = self.capture_dir_sdc + filename = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") + if self.state_manager.last_snapshot_fpath and len(self.state_manager.last_snapshot_fpath) > 4: + filename += "_" + os.path.basename(self.state_manager.last_snapshot_fpath[:-4]) + filename = filename.replace("/", ";").replace(">", ";").replace(" ; ", ";") + return "{}/{}.wav".format(path, filename) def start_recording(self, processor=None): if self.rec_proc: @@ -78,17 +72,23 @@ def start_recording(self, processor=None): return False cmd = ["/usr/local/bin/jack_capture", "--daemon", "--bitdepth", "16", "--bufsize", "30", "--maxbufsize", "120"] - if self.armed: - for port in sorted(self.armed): + single_chan = True + for chain in self.state_manager.chain_manager.chains.values(): + if chain.zynmixer_proc and chain.zynmixer_proc.controllers_dict["record"].value: + single_chan = False + if chain.zynmixer_proc.eng_code == "MI": + port = f"zynmixer_chan:output_{chain.zynmixer_proc.mixer_chan:02d}" + else: + port = f"zynmixer_bus:output_{chain.zynmixer_proc.mixer_chan:02d}" cmd.append("--port") - cmd.append(f"zynmixer:output_{port + 1:02d}a") + cmd.append(f"{port}a") cmd.append("--port") - cmd.append(f"zynmixer:output_{port + 1:02d}b") - else: + cmd.append(f"{port}b") + if single_chan: cmd.append("--port") - cmd.append("zynmixer:output_17a") + cmd.append("zynmixer_bus:output_00a") cmd.append("--port") - cmd.append("zynmixer:output_17b") + cmd.append("zynmixer_bus:output_00b") self.filename = self.state_manager.get_new_capture_fpath("wav") cmd.append(self.filename) diff --git a/zyngine/zynthian_chain.py b/zyngine/zynthian_chain.py index 0123508ff..cc4058751 100644 --- a/zyngine/zynthian_chain.py +++ b/zyngine/zynthian_chain.py @@ -4,7 +4,7 @@ # # zynthian chain # -# Copyright (C) 2015-2024 Fernando Moyano +# Copyright (C) 2015-2025 Fernando Moyano # Brian Walton # # ***************************************************************************** @@ -28,11 +28,9 @@ # Zynthian specific modules import zynautoconnect +from zyngine.zynthian_processor import zynthian_processor from zyncoder.zyncore import lib_zyncore -CHAIN_MODE_SERIES = 0 -CHAIN_MODE_PARALLEL = 1 - class zynthian_chain: @@ -61,15 +59,14 @@ def __init__(self, chain_id, midi_chan=None, midi_thru=False, audio_thru=False): # Synth/generator/special slots (should be single slot) self.synth_slots = [] self.audio_slots = [] # Audio subchain (list of lists of processors) - self.fader_pos = 0 # Position of fader in audio effects chain self.chain_id = chain_id # Chain's ID # Chain's MIDI channel - None for purely audio chain, 0xffff for *All Chains* self.midi_chan = midi_chan - self.mixer_chan = None self.zmop_index = None self.midi_thru = midi_thru # True to pass MIDI if chain empty self.audio_thru = audio_thru # True to pass audio if chain empty + self.zynmixer_proc = None # zynmixer (MI/MR) processor self.midi_in = [] self.midi_out = [] self.audio_in = [] @@ -101,7 +98,10 @@ def reset(self): self.audio_thru = True else: self.title = "" - self.audio_in = [1, 2] + if self.zynmixer_proc and self.zynmixer_proc.eng_code == "MR": + self.audio_in = [] # We don't want any direct audio input connections to buses + elif self.audio_thru: + self.audio_in = [1, 2] # Default is to route first 2 audio inputs to audio chains self.audio_out = [0] if self.is_midi(): @@ -110,8 +110,12 @@ def reset(self): self.free_zmop() self.midi_out = [] - self.current_processor = None - self.remove_all_processors() + try: + self.current_processor = self.audio_slots[0][0] + self.current_processor.reset() + except: + self.current_processor = None + self.rebuild_graph() def get_slots_by_type(self, type): """Get the list of slots @@ -140,15 +144,6 @@ def get_type(self): # Chain Management # ---------------------------------------------------------------------------- - def set_mixer_chan(self, chan): - """Set chain mixer channel - - chan : Mixer channel 0..Max Channels or None - """ - - self.mixer_chan = chan - self.rebuild_audio_graph() - def set_zmop_options(self): if self.zmop_index is not None and len(self.synth_slots) > 0: # IMPORTANT!!! Synth chains drop CC & PC messages @@ -232,8 +227,10 @@ def get_description_parts(self, basepath=False, preset=False): elif self.chain_id == 0: parts.append("Main") elif not self.synth_slots and self.audio_thru: - parts.append("Audio Input " + - ','.join([str(i) for i in self.audio_in])) + if self.zynmixer_proc and self.zynmixer_proc.eng_code == "MR": + parts.append(f"Effect Return {self.zynmixer_proc.mixer_chan}") + else: + parts.append("Audio Input " + ','.join([str(i) for i in self.audio_in])) if self.synth_slots: proc = self.synth_slots[0][0] @@ -257,13 +254,14 @@ def get_description_parts(self, basepath=False, preset=False): parts.append(preset_name) if not parts: - if self.is_audio(): - if self.is_midi(): - chain_type = "Synth" - else: - chain_type = "Audio" + if self.synth_slots: + chain_type = "Synth" + elif self.is_audio(): + chain_type = "Audio" elif self.is_midi(): chain_type = "MIDI" + else: + chain_type = "Chain" parts.append(f"{chain_type} Chain {self.chain_id}") return parts @@ -299,32 +297,33 @@ def rebuild_audio_graph(self): return self.audio_routes = {} - # Add effects chain routes - for i, slot in enumerate(self.audio_slots): + # Add audio effects chain routes + first_slot_sources = [] + if self.synth_slots: + for proc in self.synth_slots[-1]: + first_slot_sources.append(proc.get_jackname()) + elif self.zynmixer_proc and self.zynmixer_proc.eng_code == "MR": + for am_slot in self.audio_slots: + if am_slot[0].eng_code in ("MI", "MR"): + first_slot_sources = [f"zynmixer_chan:send_{am_slot[0].mixer_chan:02d}"] + break + elif self.audio_thru: + first_slot_sources = self.get_input_pairs() + prev_slot_sources = first_slot_sources + + for slot in self.audio_slots: + sources = [] for processor in slot: - sources = [] - if i < self.fader_pos: - if i == 0: - # First slot fed from synth or chain input - if self.synth_slots: - for proc in self.synth_slots[-1]: - sources.append(proc.get_jackname()) - elif self.audio_thru: - sources = self.get_input_pairs() - self.audio_routes[processor.get_jackname()] = sources - else: - for prev_proc in self.audio_slots[i - 1]: - sources.append(prev_proc.get_jackname()) - self.audio_routes[processor.get_jackname()] = sources - else: - # Post fader - if i == self.fader_pos: - self.audio_routes[processor.get_jackname()] = [ - f"zynmixer:output_{self.mixer_chan + 1:02d}"] - else: - for prev_proc in self.audio_slots[i - 1]: - sources.append(prev_proc.get_jackname()) - self.audio_routes[processor.get_jackname()] = sources + jackname = processor.get_jackname() + if jackname.startswith("zynmixer"): + jackname += f":output_{processor.mixer_chan:02d}" + sources.append(jackname) + if sources: + for jackname in sources: + if jackname.startswith("zynmixer"): + jackname = jackname.replace("output_", "input_") + self.audio_routes[jackname] = prev_slot_sources.copy() + prev_slot_sources = sources # Add special processor inputs if self.is_synth(): @@ -333,37 +332,11 @@ def rebuild_audio_graph(self): sources = self.get_input_pairs() self.audio_routes[processor.get_jackname()] = sources - if self.mixer_chan is not None: - mixer_source = [] - if self.fader_pos: - # Routing from last audio processor - for source in self.audio_slots[self.fader_pos - 1]: - mixer_source.append(source.get_jackname()) - elif self.synth_slots: - # Routing from synth processor - for proc in self.synth_slots[0]: - mixer_source.append(proc.get_jackname()) - elif self.audio_thru: - # Routing from capture ports or main chain - mixer_source = self.get_input_pairs() - # Connect end of pre-fader chain - self.audio_routes[f"zynmixer:input_{self.mixer_chan + 1:02d}"] = mixer_source - - # Connect end of post-fader chain - if self.fader_pos < len(self.audio_slots): - # Use end of post fader chain - slot = self.audio_slots[-1] - sources = [] - for processor in slot: - sources.append(processor.get_jackname()) - else: - # Use mixer channel output - # if self.mixer_chan < 16: #TODO: Get main mixbus channel from zynmixer - # sources = [] # Do not route - zynmixer will normalise outputs to main mix bus - # else: - sources = [f"zynmixer:output_{self.mixer_chan + 1:02d}"] - for output in self.get_audio_out(): - self.audio_routes[output] = sources.copy() + # Connect end of chain + # Use end of post fader chain + + for output in self.get_audio_out(): + self.audio_routes[output] = prev_slot_sources.copy() zynautoconnect.release_lock() @@ -424,8 +397,8 @@ def rebuild_midi_graph(self): # MIDI inputs but it is probably as simple to let autoconnect deal with that. for slot in self.audio_slots: for proc in slot: - self.midi_routes[proc.engine.jackname] = sources - + if proc.eng_code not in ["MI", "MR"]: + self.midi_routes[proc.engine.jackname] = sources zynautoconnect.release_lock() def rebuild_graph(self): @@ -438,16 +411,6 @@ def get_audio_out(self): """Get list of audio playback port names""" return self.audio_out.copy() - audio_out = [] - for output in self.audio_out: - if output == 0: - if self.mixer_chan < 17: - audio_out.append("zynmixer:input_18") - else: - audio_out.append("system:playback_[1,2]$") - else: - audio_out.append(output) - return audio_out def toggle_audio_out(self, out): """Toggle chain audio output @@ -498,19 +461,53 @@ def toggle_midi_out(self, dest): zynautoconnect.request_midi_connect(True) def is_audio(self): - """Returns True if chain is processes audio""" + """Returns True if chain has audio output""" - return self.mixer_chan is not None + return self.zynmixer_proc is not None def is_midi(self): - """Returns True if chain processes MIDI""" + """Returns True if chain has MIDI input""" return self.zmop_index is not None def is_synth(self): """Returns True if chain contains synth processor""" - return len(self.synth_slots) != 0 + return self.zmop_index and len(self.synth_slots) != 0 + + def is_generator(self): + """Returns True if chain is a generator => """ + + return self.zynmixer_proc and self.synth_slots and self.synth_slots[0][0].type == "Audio Generator" + + def is_special(self): + """Returns True if chain is a special chain => """ + + return self.zynmixer_proc and self.synth_slots and self.synth_slots[0][0].type == "Special" + + def is_mixbus(self): + """Returns True if chain is an Aux Mixbus chain""" + + return self.zynmixer_proc and not self.synth_slots and self.zynmixer_proc.eng_code == "MR" + + def is_audio_in(self): + """Returns True if chain is a pure audio input""" + + return self.zynmixer_proc and not self.synth_slots and self.zynmixer_proc.eng_code == "MI" + + def set_solo(self, value): + """Sets solo state in zynmixer, if audio chain""" + try: + self.zynmixer_proc.controllers_dict["solo"].set_value(value) + except: + pass + + def toggle_solo(self): + """Toggles solo state in zynmixer, if audio chain""" + try: + self.zynmixer_proc.controllers_dict["solo"].toggle() + except: + pass # --------------------------------------------------------------------------- # Processor management @@ -529,10 +526,6 @@ def get_slot_count(self, type=None): return len(self.midi_slots) elif type == "Audio Effect": return len(self.audio_slots) - elif type == "Pre Fader": - return self.fader_pos - elif type == "Post Fader": - return len(self.audio_slots) - self.fader_pos elif type == "MIDI Synth": return len(self.synth_slots) else: @@ -601,32 +594,46 @@ def get_processors_by_id(self): procs_by_id[proc.id] = proc return procs_by_id - def insert_processor(self, processor, parallel=False, slot=None): + def insert_processor(self, processor, slot=None): """Insert a processor in the chain processor : processor object to insert - parallel : True to add in parallel (same slot) else create new slot (Default: series) - slot : Position (slot) to insert within subchain (Default: End of chain) - Returns : True if processor added to chain + slot : Position (slot) to insert within subchain (Default: new slot at end of subchain, pre-fader) """ slots = self.get_slots_by_type(processor.type) if len(slots) == 0: + # Chain is empty so create a new slot for the processor slots.append([processor]) else: - if slot is None or slot < 0 or slot > len(slots): - slot = len(slots) - 1 - if parallel: - slots[slot].append(processor) + if slot is None: + # Add processor to a new slot at end position in subchain + slots.append([processor]) + elif slot < 0: + # Insert into new slot at start of chain + slots.insert(0, [processor]) + elif slot >= len(slots): + # Invalid slot so append to end of chain + slots.append([processor]) else: - slots.insert(slot + 1, [processor]) + # If mixer slot, insert before + try: + if slots[slot][0].eng_code in ("MI", "MR"): + slots.insert(slot, []) + except: + pass + # Add parallel processor to existing slot + slots[slot].append(processor) processor.set_chain(self) + if processor.engine: + processor.engine.add_processor(processor) processor.set_midi_chan(self.midi_chan) + if self.current_processor is None: + self.current_processor = processor + self.set_zmop_options() - self.current_processor = processor - return True def replace_processor(self, old_processor, new_processor): """Replace a processor within a chain @@ -658,6 +665,9 @@ def remove_processor(self, processor): Returns : True on success """ + if self.chain_id == 0 and processor.eng_code == "MR": + return False + slot = self.get_slot(processor) if slot is None: logging.error("processor is not in chain!") @@ -669,8 +679,6 @@ def remove_processor(self, processor): slots[slot].remove(processor) if len(slots[slot]) == 0: slots.pop(slot) - if processor.type == "Audio Effect" and slot < self.fader_pos: - self.fader_pos -= 1 processor.set_chain(None) if processor.engine: @@ -691,8 +699,6 @@ def remove_processor(self, processor): self.current_processor = self.midi_slots[0][0] else: self.current_processor = None - - # del processor => I don't think this is needed nor right?? (Jofemodo) return True def remove_all_processors(self): @@ -710,37 +716,43 @@ def nudge_processor(self, processor, up): slots = self.get_slots_by_type(processor.type) cur_slot = self.get_slot(processor) parallel = len(slots[cur_slot]) > 1 - is_audio = processor.type == "Audio Effect" + is_mixer_strip = processor.eng_code in ("MI", "MR") + if up: - if parallel: + if not parallel and cur_slot == 0: + return False + if is_mixer_strip: + slots.pop(cur_slot) + slots.insert(cur_slot - 1, [processor]) + elif parallel: slots[cur_slot].remove(processor) - slots.insert(cur_slot, [processor]) - if is_audio and cur_slot < self.fader_pos: - self.fader_pos += 1 - elif is_audio and cur_slot == self.fader_pos: - self.fader_pos += 1 + if slots[cur_slot][0].eng_code in ("MI", "MR"): + slots.insert(cur_slot - 1, [processor]) + else: + slots.insert(cur_slot, [processor]) elif cur_slot > 0: slots.pop(cur_slot) - slots[cur_slot - 1].append(processor) - if is_audio and cur_slot < self.fader_pos: - self.fader_pos -= 1 + if slots[cur_slot - 1][0].eng_code in ("MI", "MR"): + slots.insert(cur_slot - 1, [processor]) + else: + slots[cur_slot - 1].append(processor) else: return False else: - if parallel: + if not parallel and cur_slot + 1 >= len(slots): + return False + if is_mixer_strip: + slots.pop(cur_slot) + slots.insert(cur_slot + 1, [processor]) + elif parallel: slots[cur_slot].remove(processor) slots.insert(cur_slot + 1, [processor]) - if is_audio and cur_slot < self.fader_pos: - self.fader_pos += 1 - elif is_audio and cur_slot + 1 == self.fader_pos: - self.fader_pos -= 1 - elif cur_slot + 1 < len(slots): - slots.pop(cur_slot) - slots[cur_slot].append(processor) - if is_audio and cur_slot < self.fader_pos: - self.fader_pos -= 1 else: - return False + slots.pop(cur_slot) + if slots[cur_slot][0].eng_code in ("MI", "MR"): + slots.insert(cur_slot + 1, [processor]) + else: + slots[cur_slot].append(processor) self.rebuild_graph() except: @@ -864,11 +876,9 @@ def get_state(self): "midi_chan": self.midi_chan, "midi_thru": self.midi_thru, "audio_thru": self.audio_thru, - "mixer_chan": self.mixer_chan, "zmop_index": self.zmop_index, "cc_route": cc_route, "slots": slots_states, - "fader_pos": self.fader_pos, "zctrls": self.get_zctrls_state() } diff --git a/zyngine/zynthian_chain_manager.py b/zyngine/zynthian_chain_manager.py index cc0b51025..71ef57e5f 100644 --- a/zyngine/zynthian_chain_manager.py +++ b/zyngine/zynthian_chain_manager.py @@ -36,27 +36,27 @@ from zyngine.zynthian_engine_pianoteq import * from zyngine.zynthian_signal_manager import zynsigman from zyngine.zynthian_processor import zynthian_processor +from zyngine import zynthian_state_manager from zyngui import zynthian_gui_config # ---------------------------------------------------------------------------- # Some variables & definitions # ---------------------------------------------------------------------------- -MAX_NUM_MIDI_CHANS = 16 +MAX_NUM_MIDI_CHANS = 32 -# TODO: Get this from zynmixer -MAX_NUM_MIXER_CHANS = 16 - -# Get ZYnMidiRouter parameters and limits from lib_zyncore +# Get ZynMidiRouter parameters and limits from lib_zyncore NUM_ZMOP_CHAINS = lib_zyncore.zmop_get_num_chains() MAX_NUM_ZMOPS = NUM_ZMOP_CHAINS - 1 -NUM_MIDI_DEVS_IN = lib_zyncore.zmip_get_num_devs() + 3 +NUM_MIDI_DEVS_IN = lib_zyncore.zmip_get_num_devs() + 3 #TODO: Use a constant for this extra capacity NUM_MIDI_DEVS_OUT = lib_zyncore.zmop_get_num_devs() MAX_NUM_MIDI_DEVS = min(NUM_MIDI_DEVS_IN, NUM_MIDI_DEVS_OUT) ZMIP_SEQ_INDEX = lib_zyncore.zmip_get_seq_index() ZMIP_STEP_INDEX = lib_zyncore.zmip_get_step_index() ZMIP_INT_INDEX = lib_zyncore.zmip_get_int_index() ZMIP_CTRL_INDEX = lib_zyncore.zmip_get_ctrl_index() +ZMOP_MOD_INDEX = lib_zyncore.zmop_get_mod_index() +ZMOP_STEP_INDEX = lib_zyncore.zmop_get_step_index() engine2class = { "ZY": zynthian_engine_zynaddsubfx, @@ -66,14 +66,19 @@ "BF": zynthian_engine_setbfree, 'JV': zynthian_engine_jalv, "AE": zynthian_engine_aeolus, - 'PT': zynthian_engine_pianoteq, + "PT": zynthian_engine_pianoteq, "AP": zynthian_engine_audioplayer, "SL": zynthian_engine_sooperlooper, - 'SX': zynthian_engine_sysex, - 'MC': zynthian_engine_midi_control, - 'PD': zynthian_engine_puredata, - 'MD': zynthian_engine_modui, - 'IR': zynthian_engine_inet_radio + "SX": zynthian_engine_sysex, + "MC": zynthian_engine_midi_control, + "PD": zynthian_engine_puredata, + "MD": zynthian_engine_modui, + "IR": zynthian_engine_inet_radio, + "MI": zynthian_engine_audio_mixer, + "MR": zynthian_engine_audio_mixer, + "MX": zynthian_engine_alsa_mixer, + "TP": zynthian_engine_tempo, + 'CL': zynthian_engine_clippy } # ---------------------------------------------------------------------------- @@ -89,8 +94,9 @@ class zynthian_chain_manager: SS_ADD_CHAIN = 3 SS_REMOVE_CHAIN = 4 SS_REMOVE_ALL_CHAINS = 5 - SS_ADD_PROCESSOR = 6 - SS_REMOVE_PROCESSOR = 7 + SS_RENAME_CHAIN = 6 + SS_ADD_PROCESSOR = 7 + SS_REMOVE_PROCESSOR = 8 engine_info = None single_processor_engines = ["BF", "MD", "PT", "AE", "SL", "IR"] @@ -110,17 +116,17 @@ def __init__(self, state_manager): self.state_manager = state_manager self.chains = {} # Map of chain objects indexed by chain id - self.ordered_chain_ids = [] # List of chain IDs in display order self.zyngine_counter = 0 # Appended to engine names for uniqueness - self.zyngines = {} # List of instantiated engines + self.zyngines = {} # Map of instantiated engines, indexed by engine code self.processors = {} # Dictionary of processor objects indexed by UID - self.active_chain_id = None # Active chain id - self.midi_chan_2_chain_ids = [list() for _ in range(MAX_NUM_MIDI_CHANS)] # Chain IDs mapped by MIDI channel + self.active_chain = None # Active chain object => This should NEVER be None!!! + self._pinned_chains = 1 # Quantity of pinned chains (shown pinned to right edge of mixer in UI) # Map of list of zctrls indexed by 24-bit ZMOP,CHAN,CC self.absolute_midi_cc_binding = {} # Map of list of zctrls indexed by 24-bit CHAIN,CHAN,CC self.chain_midi_cc_binding = {} + self.rebuild_optimisation_cache() # ------------------------------------------------------------------------ # Engine Management @@ -139,6 +145,10 @@ def get_engine_info(cls): return cls.engine_info cls.engine_info = eng_info + cls.engine_info["MI"] = {"ID":"0", "NAME":"Mixer_Channel_Strip", "TITLE": "Mixer Channel Strip", "TYPE": "Audio Effect", "CAT": "Other", "ENABLED": False, "INDEX": 0, "URL": "", "UI": "", "DESCR": "Audio mixer channel strip", "QUALITY": 5, "COMPLEX": 5, "EDIT": 0} + cls.engine_info["MR"] = {"ID":"1", "NAME":"Mixer_Return_Strip", "TITLE": "Mixer Effect Return Strip", "TYPE": "Audio Effect", "CAT": "Other", "ENABLED": False, "INDEX": 1, "URL": "", "UI": "", "DESCR": "Audio mixer effect return strip", "QUALITY": 5, "COMPLEX": 5, "EDIT": 0} + cls.engine_info["MX"] = {"NAME": "Alsa_Mixer", "TITLE": "ALSA Mixer", "TYPE": "Global", "CAT": None, "ENGINE": zynthian_engine_alsa_mixer, "ENABLED": False} + cls.engine_info["TP"] = {"NAME": "Tempo", "TITLE": "Tempo", "TYPE": "Global", "CAT": None, "ENGINE": zynthian_engine_tempo, "ENABLED": False} # Look for an engine class for each one for key, info in cls.engine_info.items(): try: @@ -170,7 +180,7 @@ def save_engine_info(cls): # Chain Management # ------------------------------------------------------------------------ - def add_chain(self, chain_id, midi_chan=None, midi_thru=False, audio_thru=False, mixer_chan=None, zmop_index=None, + def add_chain(self, chain_id, midi_chan=None, midi_thru=False, audio_thru=False, zmop_index=None, title="", chain_pos=None, fast_refresh=True): """Add a chain @@ -178,7 +188,6 @@ def add_chain(self, chain_id, midi_chan=None, midi_thru=False, audio_thru=False, midi_chan : MIDI channel associated with chain midi_thru : True to enable MIDI thru for empty chain (Default: False) audio_thru : True to enable audio thru for empty chain (Default: False) - mixer_chan : Mixer channel (Default: None) zmop_index : MIDI router output (Default: None) title : Chain title (Default: None) chain_pos : Position to insert chain (Default: End) @@ -195,11 +204,13 @@ def add_chain(self, chain_id, midi_chan=None, midi_thru=False, audio_thru=False, chain_id += 1 chain_id = int(chain_id) + if chain_pos is None: + chain_pos = len(self.chains) - 1 + # If Main chain ... if chain_id == 0: # main midi_thru = False audio_thru = True - mixer_chan = self.state_manager.zynmixer.MAX_NUM_CHANNELS - 1 # If the chain already exists, update and return if chain_id in self.chains: @@ -208,33 +219,48 @@ def add_chain(self, chain_id, midi_chan=None, midi_thru=False, audio_thru=False, self.state_manager.end_busy("add_chain") return chain_id + # Enable sequencer channel + if midi_chan is not None: + self.state_manager.zynseq.enable_channel(midi_chan, True) + + """ + # Enable launcher sequences if not used by other chain + if midi_chan is not None: + enable_sequences = True + for chain in self.chains.values(): + if chain.midi_chan == midi_chan: + enable_sequences = False + break + if enable_sequences: + self.state_manager.zynseq.enable_channel(midi_chan, True) + """ + # Create chain instance chain = zynthian_chain(chain_id, midi_chan, midi_thru, audio_thru) if not chain: return None + + # Insert chain into dict + items = list(self.chains.items()) + items.insert(chain_pos, (chain_id, chain)) + self.chains = dict(items) self.chains[chain_id] = chain + # Update pinned chains + if self._pinned_chains > 1 and chain_pos >= self.get_pinned_pos(): + self._pinned_chains += 1 # Setup chain chain.set_title(title) - # If a mixer_chan is specified (restore from state), setup mixer_chan - if mixer_chan is not None: - chain.set_mixer_chan(mixer_chan) - # else, if audio_thru enabled, setup a mixer_chan - elif audio_thru: - try: - chain.set_mixer_chan(self.get_next_free_mixer_chan()) - except Exception as e: - logging.warning(e) - self.state_manager.end_busy("add_chain") - return None + + # Set MIDI channel + self.set_midi_chan(chain_id, midi_chan) # Setup MIDI routing - if isinstance(midi_chan, int): + if isinstance(midi_chan, int) and (0 <= midi_chan < 16 or midi_chan == 0xffff): # Restore zmop_index if it's free for assignment if zmop_index is None or not self.is_free_zmop_index(zmop_index): zmop_index = self.get_next_free_zmop_index() chain.set_zmop_index(zmop_index) - if chain.zmop_index is not None: # Enable all MIDI input devices by default => TODO: Should we allow user to define default routing? for zmip in range(MAX_NUM_MIDI_DEVS): lib_zyncore.zmop_set_route_from(chain.zmop_index, zmip, True) @@ -242,7 +268,7 @@ def add_chain(self, chain_id, midi_chan=None, midi_thru=False, audio_thru=False, lib_zyncore.zmop_set_route_from(chain.zmop_index, ZMIP_STEP_INDEX, True) # Enable SMF sequencer MIDI intput lib_zyncore.zmop_set_route_from(chain.zmop_index, ZMIP_SEQ_INDEX, True) - # Enable CV/Gate MIDI intput (fake port zmip) + # Enable CV/Gate MIDI input (fake port zmip) lib_zyncore.zmop_set_route_from(chain.zmop_index, ZMIP_INT_INDEX, True) # Enable default native CC handling of pedals cc_route_ct = (ctypes.c_uint8 * 128)() @@ -250,25 +276,17 @@ def add_chain(self, chain_id, midi_chan=None, midi_thru=False, audio_thru=False, cc_route_ct[ccnum] = 1 lib_zyncore.zmop_set_cc_route(zmop_index, cc_route_ct) - # Set MIDI channel - self.set_midi_chan(chain_id, midi_chan) - - # Add to chain index (sorted!) - if chain_pos is None: - chain_pos = self.get_chain_index(0) - self.ordered_chain_ids.insert(chain_pos, chain_id) - chain.rebuild_graph() zynautoconnect.request_audio_connect(fast_refresh) zynautoconnect.request_midi_connect(fast_refresh) - logging.debug(f"ADDED CHAIN {chain_id} => midi_chan={chain.midi_chan}, mixer_chan={chain.mixer_chan}, zmop_index={chain.zmop_index}") - # logging.debug(f"ordered_chain_ids = {self.ordered_chain_ids}") - # logging.debug(f"midi_chan_2_chain_ids = {self.midi_chan_2_chain_ids}") + logging.debug(f"ADDED CHAIN {chain_id} => midi_chan={chain.midi_chan}, zmop_index={chain.zmop_index}") - self.active_chain_id = chain_id + self.set_active_chain_by_id(chain_id) if fast_refresh: zynsigman.send_queued(zynsigman.S_CHAIN_MAN, self.SS_ADD_CHAIN) + else: + self.rebuild_optimisation_cache() self.state_manager.end_busy("add_chain") return chain_id @@ -289,17 +307,13 @@ def add_chain_from_state(self, chain_id, chain_state): audio_thru = chain_state['audio_thru'] else: audio_thru = False - if 'mixer_chan' in chain_state: - mixer_chan = chain_state['mixer_chan'] - else: - mixer_chan = None if 'zmop_index' in chain_state: zmop_index = chain_state['zmop_index'] else: zmop_index = None chain_id = self.add_chain(chain_id, midi_chan=midi_chan, midi_thru=midi_thru, audio_thru=audio_thru, - mixer_chan=mixer_chan, zmop_index=zmop_index, title=title, fast_refresh=False) + zmop_index=zmop_index, title=title, fast_refresh=False) # Set CC route state zmop_index = self.chains[chain_id].zmop_index @@ -326,6 +340,7 @@ def remove_chain(self, chain_id, stop_engines=True, fast_refresh=True): # List of associated chains that shold be removed simultaneously chains_to_remove = [chain_id] chain = self.chains[chain_id] + midi_chan = chain.midi_chan if chain.synth_slots: if chain.synth_slots[0][0].eng_code in ["BF", "AE"]: # TODO: We remove all setBfree and Aeolus chains but maybe we should allow chain manipulation @@ -337,45 +352,60 @@ def remove_chain(self, chain_id, stop_engines=True, fast_refresh=True): chain = self.chains[chain_id] if isinstance(chain.midi_chan, int): if chain.midi_chan < MAX_NUM_MIDI_CHANS: - self.midi_chan_2_chain_ids[chain.midi_chan].remove(chain_id) lib_zyncore.ui_send_ccontrol_change(chain.midi_chan, 120, 0) elif chain.midi_chan == 0xffff: for mc in range(16): - self.midi_chan_2_chain_ids[mc].remove(chain_id) lib_zyncore.ui_send_ccontrol_change(mc, 120, 0) + if self._pinned_chains > 1 and chain_pos >= self.get_pinned_pos(): + self._pinned_chains -= 1 - if chain.mixer_chan is not None: - mute = self.state_manager.zynmixer.get_mute(chain.mixer_chan) - self.state_manager.zynmixer.set_mute(chain.mixer_chan, True, True) + update_fxreturns = False + if chain.zynmixer_proc: + chain.zynmixer_proc.zynmixer.set_mute(chain.zynmixer_proc.mixer_chan, True) # Mute chain whilst removing + sleep(self.state_manager.jack_period) + if chain.zynmixer_proc.eng_code == "MR" and chain.chain_id: + update_fxreturns = True for processor in chain.get_processors(): - self.remove_processor(chain_id, processor, False, False) - + self.remove_processor(chain_id, processor, False) chain.reset() if chain_id != 0: - if chain.mixer_chan is not None: - self.state_manager.zynmixer.reset(chain.mixer_chan) - self.state_manager.audio_recorder.unarm(chain.mixer_chan) self.chains.pop(chain_id) - self.state_manager.zynmixer.set_mute( - chain.mixer_chan, False, True) del chain - if chain_id in self.ordered_chain_ids: - self.ordered_chain_ids.remove(chain_id) - elif chain.mixer_chan is not None: - self.state_manager.zynmixer.set_mute(chain.mixer_chan, mute, True) + + self.rebuild_optimisation_cache() zynautoconnect.request_audio_connect(fast_refresh) zynautoconnect.request_midi_connect(fast_refresh) if stop_engines: self.stop_unused_engines() - if self.active_chain_id not in self.chains: - if chain_pos + 1 >= len(self.ordered_chain_ids): + if self.active_chain not in self.chains.values(): + if chain_pos + 1 >= len(self.chains): chain_pos -= 1 self.set_active_chain_by_index(chain_pos) + + # Disable launcher sequences if not used by other chain + if midi_chan is not None: + disable_sequences = True + for chain in self.chains.values(): + if chain.midi_chan == midi_chan: + disable_sequences = False + break + if disable_sequences: + self.state_manager.zynseq.enable_channel(midi_chan, False) + self.state_manager.purge_zs3() + + if update_fxreturns: + i = 1 + for chain_id in self.chains: + chain = self.chains[chain_id] + if chain.title.startswith("Effect Return "): + chain.title = f"Effect Return {i}" + i += 1 if fast_refresh: zynsigman.send_queued(zynsigman.S_CHAIN_MAN, self.SS_REMOVE_CHAIN) + self.state_manager.end_busy("remove_chain") return True @@ -388,90 +418,333 @@ def remove_all_chains(self, stop_engines=True): """ success = True - for chain in list(self.chains.keys()): - success &= self.remove_chain(chain, stop_engines, fast_refresh=False) + for chain_id in list(self.chains.keys()): + success &= self.remove_chain(chain_id, stop_engines, fast_refresh=False) + self._pinned_chains = 1 zynsigman.send_queued(zynsigman.S_CHAIN_MAN, self.SS_REMOVE_ALL_CHAINS) return success - def move_chain(self, offset, chain_id=None): + def set_chain_title(self, chain_id, title): + try: + chain = self.chains[chain_id] + if chain.get_title() == title: + return + chain.set_title(title) + if chain.chain_id and chain.zynmixer_proc and chain.zynmixer_proc.eng_code == "MR": + self.refresh_mixbus_sends() + zynsigman.send_queued(zynsigman.S_CHAIN_MAN, self.SS_RENAME_CHAIN, chain_id=chain_id, title=title) + except: + pass + + def nudge_chain(self, offset): + """Move active chain's position relative to current position + + offset - Position to move to relative to current position (+/-) + Returns - New position of chain + """ + + try: + pos = list(self.chains).index(self.active_chain.chain_id) + offset + except: + return None + return self.move_chain(self.active_chain.chain_id, pos) + + def move_chain(self, chain_id, pos): """Move a chain's position chain_id - Chain id - offset - Position to move to relative to current position (+/-) + pos - Position to move to + Returns - New position of chain or None on failure """ if chain_id is None: - chain_id = self.active_chain_id - if chain_id and chain_id in self.ordered_chain_ids: - index = self.ordered_chain_ids.index(chain_id) - pos = index + offset - pos = min(pos, len(self.ordered_chain_ids) - 2) - pos = max(pos, 0) - self.ordered_chain_ids.insert( - pos, self.ordered_chain_ids.pop(index)) - zynsigman.send(zynsigman.S_CHAIN_MAN, self.SS_MOVE_CHAIN) + chain_id = self.active_chain.chain_id + if not chain_id or chain_id not in self.chains: + return None + index = list(self.chains).index(chain_id) + div = self.get_pinned_pos() + if index < div and pos >= div: + self._pinned_chains += 1 + pos -= 1 + elif index >= div and pos < div: + if self._pinned_chains > 1: + self._pinned_chains -= 1 + pos += 1 + pos = min(pos, len(self.chains) - 2) + pos = max(pos, 0) + + if pos == index: + return pos + + value = self.chains.pop(chain_id) + items = list(self.chains.items()) + items.insert(pos, (chain_id, value)) + self.chains = dict(items) + + chain = self.chains[chain_id] + if chain.zynmixer_proc and chain.zynmixer_proc.eng_code == "MR": + # Moved a mixbus (effects return) so update sends and default mixbus names + send = 1 + for chain in self.chains.values(): + parts = chain.title.split("Aux Mixbus ") + if len(parts) > 1: + try: + parts = parts[1].split(" ") + parts[0] = str(send) + chain.title = f"Aux Mixbus {' '.join(parts)}" + except: + pass + send += 1 + self.refresh_mixbus_sends() + self.rebuild_optimisation_cache() + zynsigman.send(zynsigman.S_CHAIN_MAN, self.SS_MOVE_CHAIN) + return pos + + # --- Chain getter functions --- + + def get_active_chain(self): + """Get the active chain object or None if no active chain + + Returns: Chain object or None on failure + """ - def get_chain_count(self): - """Get the quantity of chains""" + if self.active_chain.chain_id in self.chains: + return self.chains[self.active_chain.chain_id] + return None + + def get_active_chain_index(self): + """Get the active chain object or None if no active chain + + Returns: Chain object or None on failure + """ - return len(self.chains) + return self.get_chain_index(self.active_chain.chain_id) + + def get_chain_index(self, chain_id): + """ Get the index of a chain from its displayed order + Args: + chain_id: Chain id + Returns: Index of chain or last chain if chain_id not found + """ + + if chain_id in self.chains: + return list(self.chains).index(chain_id) + return len(self.chains) - 1 + + def get_chain_count(self, audio=True, midi=True, synth=True): + """ Get the quantity of chains + Args: + audio : True to include audio chains + midi : True to include MIDI chains + synth : True to include synth chains + Returns: Quantity of chains + """ + + if audio and midi and synth: + return len(self.chains) + + count = 0 + for chain in self.chains.values(): + if chain.is_midi() == midi or chain.is_audio() == audio or chain.is_synth() == synth: + count += 1 + return count def get_chain(self, chain_id): - """Get a chain object by id""" + """ Get a chain object by its id + Args: + chain_id: Chain identifier (int) + Returns: Chain object or None on failure + """ try: return self.chains[chain_id] - except: + except KeyError: + return None + + def get_chain_id_by_index(self, index): + """ Get a chain ID by its display index + Args: + index: Display position + Returns: Chain identifier (int) or None on failure + Note: You may use negative index to count back from end, e.g. index=-1 for last chain (which is always the main mixbus) + """ + + try: + return list(self.chains)[index] + except IndexError: return None def get_chain_by_index(self, index): - """Get a chain object by the index""" + """ Get a chain object by its display index + Args: + index: Display position + Returns: Chain object or None on failure + Note: You may use negative index to count back from end, e.g. index=-1 for last chain (which is always the main mixbus) + """ try: - return self.chains[self.ordered_chain_ids[index]] - except: + return self.chains[self.get_chain_id_by_index(index)] + except KeyError: return None def get_chain_by_position(self, pos, audio=True, midi=True, synth=True): - """Get a chain by its (display) position - - pos : Display position (0..no of chains) - audio_only : True to include audio chains - midi : True to include MIDI chains - synth : True to include synth chains - returns : Chain object or None if not found + """ Get a chain by its (display) position with option to filter + Args: + pos : Display position (0..no of chains) + audio : True to include audio chains + midi : True to include MIDI chains + synth : True to include synth chains + Returns : Chain object or None if not found """ if audio and midi and synth: - if pos < len(self.ordered_chain_ids): - return self.chains[self.ordered_chain_ids[pos]] - else: - return None + return self.get_chain_by_index(pos) - for chain_id in self.ordered_chain_ids: - chain = self.chains[chain_id] - if chain.is_midi() == midi or chain.is_audio() == audio or chain.is_synth == synth: + for chain in self.chains.values(): + if chain.is_midi() == midi or chain.is_audio() == audio or chain.is_synth() == synth: if pos == 0: - return self.chains[chain_id] + return chain pos -= 1 return None - def get_chain_id_by_index(self, index): - """Get a chain ID by the index""" + def get_chain_ids_filtered(self, filter=None): + """ Get chain list filtered and ordered in display order + Args: + filter : A list of chain types to filter => ["audio", "midi", "synth", "generator", "audio_out", "audio_in", "mixbus"] + Returns : List of chain identifiers + """ + + if not filter: + return list(self.chains) + + chain_ids_filtered = [] + for chain_id, chain in self.chains.items(): + for type in filter: + try: + if getattr(chain, f"is_{type}")(): + chain_ids_filtered.append(chain_id) + except: + pass + return chain_ids_filtered + + def get_chain_id_by_mixer_chan(self, chan, mixbus=False): + """ Get a chain by the mixer channel + Args: + chan: Mixer channel index + mixbus: True to look for mixbus channels + Returns: Chain identifier or None on failure + """ try: - return self.ordered_chain_ids[index] + pos = self._mixer_chan_2_pos[mixbus][chan] + return list(self.chains)[pos] except: return None - def get_chain_id_by_mixer_chan(self, chan): - """Get a chain by the mixer channel""" + #if mixbus: + # eng_code = "MR" + #else: + # eng_code = "MI" + #for chain_id, chain in self.chains.items(): + # if chain.zynmixer_proc and chain.zynmixer_proc.mixer_chan == chan and chain.zynmixer_proc.eng_code == eng_code: + # return chain_id + #return None + + def get_pos_by_mixer_chan(self, chan, mixbus=False): + """ Get display position of a chain by the mixer channel + Args: + chan: Mixer channel index + mixbus: True to look for mixbus channels + Returns: Chain position or None on failure + """ - for chain_id, chain in self.chains.items(): - if chain.mixer_chan is not None and chain.mixer_chan == chan: - return chain_id - return None + try: + return self._mixer_chan_2_pos[mixbus][chan] + except: + return None + + def get_pos_by_midi_chan(self, chan): + """ Get a list of display positions (columns) for chains with specified MIDI channel + + Args: + chan: MIDI channel + Returns: List of display positions (columns). May be empty list. + """ + + try: + return self._midi_chan_2_pos[chan] + except IndexError: + return [] + + def get_chain_ids_by_midi_chan(self, chan): + """ Get a list of chain identifiers for chains with specified MIDI channel + + Args: + chan: MIDI channel + Returns: List of chain identifiers. May be empty list. + """ + + try: + return self._midi_chan_2_chain_ids[chan] + except IndexError: + return [] + + def get_send_id(self, idx): + """ Get chain identifier for an effects send/return mixbus + + Args: + idx: Index of the effect send/return/mixbus + Returns: + Chain identifier or None on failure. + """ + try: + return self._sends[idx] + except IndexError: + return None + + def rebuild_optimisation_cache(self): + self._midi_chan_2_chain_ids = [list() for _ in range(MAX_NUM_MIDI_CHANS)] # List of lists of chain ids, indexed by midi channel. + self._midi_chan_2_pos = [list() for _ in range(MAX_NUM_MIDI_CHANS)] # List of lists of chain positions, indexed by midi channel. + self._mixer_chan_2_pos = [{},{}] # Map of chain positions, indexed by mixer chan. First map is for chains. Second map is for mixbuses. + self._sends = [] # List of FX send/return mixbus chain_ids + for pos, chain in enumerate(self.chains.values()): + try: + self._midi_chan_2_chain_ids[chain.midi_chan].append(chain.chain_id) + self._midi_chan_2_pos[chain.midi_chan].append(pos) + except: + pass + if chain.zynmixer_proc: + self._mixer_chan_2_pos[chain.zynmixer_proc.eng_code == "MR"][chain.zynmixer_proc.mixer_chan] = pos + if chain.zynmixer_proc.eng_code == "MR": + self._mixer_chan_2_pos[1][chain.zynmixer_proc.mixer_chan] = pos + if chain.chain_id: + self._sends.append(chain.chain_id) + + # --- Pinned chain managment--- + def set_pinned(self, count): + """ Set the quantity of pinned chains + Args: + count: Quantity of chains to pin to right hand edge of UI + Note: Includes main mixbus which must always be pinned, so minimum count is 1 + """ + + if count: + self._pinned_chains = count + + def get_pinned_count(self): + """ Get the quantity of pinned chains + Returns: Quantity of pinned chains + """ + return self._pinned_chains + + def get_pinned_pos(self): + """ Get the index of the first pinned chain + Returns: + Position of first pinned chain + """ + + return max(0, len(self.chains) - self._pinned_chains) # ------------------------------------------------------------------------ # Chain Input/Output and Routing Management @@ -653,29 +926,29 @@ def set_active_chain_by_id(self, chain_id=None): """ if chain_id is None: - chain_id = self.active_chain_id - - try: - chain = self.chains[chain_id] - except: - chain = None + chain = self.active_chain + else: + try: + chain = self.chains[chain_id] + except: + chain = None # If no better candidate, set active the first chain (Main) if chain is None: chain = next(iter(self.chains.values())) - chain_id = chain.chain_id - - self.active_chain_id = chain_id - zynsigman.send_queued(zynsigman.S_CHAIN_MAN, self.SS_SET_ACTIVE_CHAIN, active_chain=self.active_chain_id) - # If chain receives MIDI, set the active chain in ZynMidiRouter (lib_zyncore) - if isinstance(chain.zmop_index, int): - try: - lib_zyncore.set_active_chain(chain.zmop_index) - except Exception as e: - logging.error(e) + if self.active_chain != chain: + self.active_chain = chain + self.state_manager.zynseq.chan = chain.midi_chan + zynsigman.send_queued(zynsigman.S_CHAIN_MAN, self.SS_SET_ACTIVE_CHAIN, active_chain_id=self.active_chain.chain_id) + # If chain receives MIDI, set the active chain in ZynMidiRouter (lib_zyncore) + if isinstance(chain.zmop_index, int): + try: + lib_zyncore.set_active_chain(chain.zmop_index) + except Exception as e: + logging.error(e) - return self.active_chain_id + return self.active_chain.chain_id def set_active_chain_by_object(self, chain_object): """Select the active chain @@ -688,17 +961,17 @@ def set_active_chain_by_object(self, chain_object): if self.chains[id] == chain_object: self.set_active_chain_by_id(id) break - return self.active_chain_id + return self.active_chain.chain_id def set_active_chain_by_index(self, index): - """Select the active chain by index + """Select the active chain by display index - index : Index of chain in ordered_chain_ids + index : Index of chain in display order Returns : ID of active chain """ - if index < len(self.ordered_chain_ids): - return self.set_active_chain_by_id(self.ordered_chain_ids[index]) + if 0 <= index < len(self.chains): + return self.set_active_chain_by_id(self.get_chain_id_by_index(index)) else: return self.set_active_chain_by_id(0) @@ -709,10 +982,10 @@ def next_chain(self, nudge=1): Returns : Chain ID """ - index = self.get_chain_index(self.active_chain_id) + index = self.get_chain_index(self.active_chain.chain_id) index += nudge - index = min(index, len(self.ordered_chain_ids) - 1) index = max(index, 0) + index = min(index, len(self.chains) - 1) return self.set_active_chain_by_index(index) def previous_chain(self, nudge=1): @@ -725,29 +998,11 @@ def previous_chain(self, nudge=1): return self.next_chain(-nudge) def rotate_chain(self): - if self.active_chain_id > 0: + if self.active_chain.chain_id > 0: return self.next_chain() else: return self.set_active_chain_by_index(0) - def get_active_chain(self): - """Get the active chain object or None if no active chain""" - - if self.active_chain_id in self.chains: - return self.chains[self.active_chain_id] - return None - - def get_chain_index(self, chain_id): - """Get the index of a chain from its displayed order - - chain_id : Chain id - returns : Index or 0 if not found - """ - - if chain_id in self.ordered_chain_ids: - return self.ordered_chain_ids.index(chain_id) - return 0 - # ------------------------------------------------------------------------ # Processor Management # ------------------------------------------------------------------------ @@ -756,44 +1011,37 @@ def get_available_processor_id(self): """Get the next available processor ID""" proc_ids = list(self.processors) - if proc_ids: - proc_ids.sort() - for x, y in enumerate(proc_ids): - if proc_ids[x-1] + 1 < y: - return proc_ids[x-1]+1 - return proc_ids[-1] + 1 - else: - return 1 + proc_ids.sort() + id = 0 + while id in proc_ids: + id += 1 + return id - def add_processor(self, chain_id, eng_code, parallel=False, slot=None, proc_id=None, post_fader=False, - fast_refresh=True, eng_config=None, midi_autolearn=True): + def add_processor(self, chain_id, eng_code, slot=None, proc_id=None, fast_refresh=True, eng_config=None, midi_autolearn=True): """Add a processor to a chain chain : Chain ID eng_code : Engine's code - parallel : True to add in parallel (same slot) else create new slot (Default: series) slot : Slot (position) within subchain (0..last slot, Default: last slot) proc_id : Processor UID (Default: Use next available ID) - post_fader : True to move the fader position - fast_refresh : False to trigger slow autoconnect (Default: Fast autoconnect) eng_config: Extended configuration for the engine (optional) midi_autolearn: True to auto-learn MIDI-CC based controllers (i.e. False when creating from state) Returns : processor object or None on failure """ - if chain_id not in self.chains: + if chain_id is not None and chain_id not in self.chains: logging.error(f"Chain '{chain_id}' doesn't exist!") return None + if eng_code not in self.engine_info: if eng_code != 'None': logging.error(f"Engine '{eng_code}' not found!") return None if proc_id is None: - # TODO: Derive next available processor id from self.processors proc_id = self.get_available_processor_id() send_signal = True elif proc_id in self.processors: - logging.error(f"Processor '{proc_id}' already exist!") + logging.error(f"Processor '{proc_id}' already exists!") return None else: send_signal = False @@ -806,43 +1054,62 @@ def add_processor(self, chain_id, eng_code, parallel=False, slot=None, proc_id=N logging.debug(f"Adding processor '{eng_code}' with ID '{proc_id}'") processor = zynthian_processor(eng_code, self.engine_info[eng_code], proc_id) processor.set_midi_autolearn(midi_autolearn) - chain = self.chains[chain_id] # Add proc early to allow engines to add more as required, e.g. Aeolus self.processors[proc_id] = processor - if chain.insert_processor(processor, parallel, slot): - if not parallel and not post_fader and processor.type == "Audio Effect": - chain.fader_pos += 1 - # TODO: Fails to detect MIDI only chains in snapshots - if chain.mixer_chan is None and processor.type != "MIDI Tool": - try: - chain.mixer_chan = self.get_next_free_mixer_chan() - except Exception as e: - logging.warning(e) - return None - engine = self.start_engine(processor, eng_code, eng_config) - if engine: - chain.rebuild_graph() - # Update group chains - for src_chain in self.chains.values(): - if chain_id in src_chain.audio_out: - src_chain.rebuild_graph() - zynautoconnect.request_audio_connect(fast_refresh) - zynautoconnect.request_midi_connect(fast_refresh) - # Signal processor creation, except when creating from state (loading snapshot) - if send_signal: - zynsigman.send_queued(zynsigman.S_CHAIN_MAN, self.SS_ADD_PROCESSOR) - # Success!! => Return processor - self.state_manager.end_busy("add_processor") - return processor - else: - chain.remove_processor(processor) - logging.error(f"Failed to start engine '{eng_code}'!") - else: - logging.error(f"Failed to insert processor '{proc_id}' in chain '{chain_id}', slot '{slot}'!") - # Failed!! => Remove processor from list - del self.processors[proc_id] + + if chain_id is not None: + chain = self.chains[chain_id] + chain.insert_processor(processor, slot) + # Update when adding new (proc_id = None) + if send_signal: + chain.current_processor = processor + + engine = self.start_engine(processor, eng_code, eng_config) + if not engine: + # Failed!! => Remove processor from list + del self.processors[proc_id] + self.state_manager.end_busy("add_processor") + return None + + if chain_id is None: + # Global processors not in any chain + self.state_manager.end_busy("add_processor") + return processor + + if eng_code in ("MI", "MR"): + chain.zynmixer_proc = processor + # Add FX sends to existing chains + self.refresh_mixbus_sends() + + # Update group chains + for src_chain in self.chains.values(): + if chain_id in src_chain.audio_out: + src_chain.rebuild_graph() + chain.rebuild_graph() + # Signal processor creation, except when creating from state (loading snapshot) + if send_signal: + zynsigman.send_queued(zynsigman.S_CHAIN_MAN, self.SS_ADD_PROCESSOR) self.state_manager.end_busy("add_processor") - return None + # Success!! => Return processor + return processor + + def can_move_processor(self, processor): + """ Can processor be moved? + Args: + Processor: Processor object + Returns: True if processor can be moved within chain or to another chain + """ + + if not processor or processor.type in ("MIDI Synth", "Audio Generator"): + return False + if processor.type == "Audio Effect" and processor.eng_code not in ["MI", "MR"] and self.get_chain_count(True, False, True) > 1: + return True + elif processor.type == "MIDI Tool" and self.get_chain_count(False, True, True) > 1: + return True + slots = processor.chain.get_slots_by_type(processor.type) + if len(slots) > 1 or len(slots) and len(slots[0]) > 1: + return True + return False def nudge_processor(self, chain_id, processor, up): if chain_id not in self.chains: @@ -854,22 +1121,21 @@ def nudge_processor(self, chain_id, processor, up): if chain_id in src_chain.audio_out: src_chain.rebuild_graph() - if chain.mixer_chan is not None: + if chain.is_audio(): # Audio chain so mute main output whilst making change (blunt but effective) - mute = self.state_manager.zynmixer.get_mute(255) - self.state_manager.zynmixer.set_mute(255, True, False) + mute = self.state_manager.zynmixer_bus.get_mute(0) + self.state_manager.mute() zynautoconnect.request_audio_connect(True) - self.state_manager.zynmixer.set_mute(255, mute, False) + self.state_manager.mute(mute) zynautoconnect.request_midi_connect(True) return True - def remove_processor(self, chain_id, processor, stop_engine=True, autoroute=True): + def remove_processor(self, chain_id, processor, stop_engine=True): """Remove a processor from a chain chain : Chain id processor : Instance of processor stop_engine : True to stop unused engine - autoroute : True to trigger immediate autoconnect (Default: Autoconnect) Returns : True on success """ @@ -878,25 +1144,28 @@ def remove_processor(self, chain_id, processor, stop_engine=True, autoroute=True return False if not isinstance(processor, zynthian_processor): - logging.error( - f"Invalid processor instance '{processor}' can't be removed from chain {chain_id}!") + logging.error(f"Invalid processor instance '{processor}' can't be removed from chain {chain_id}!") return False if self.state_manager.is_busy(): - self.state_manager.start_busy( - "remove_processor", None, f"removing {processor.get_basepath()} from chain {chain_id}") + self.state_manager.start_busy("remove_processor", None, f"removing {processor.get_basepath()} from chain {chain_id}") else: self.state_manager.start_busy( "remove_processor", "Removing Processor", f"removing {processor.get_basepath()} from chain {chain_id}") - for param in processor.controllers_dict: - self.remove_midi_learn(processor, param) + + for symbol in processor.controllers_dict: + self.remove_midi_learn(processor, symbol) id = None for i, p in self.processors.items(): if processor == p: id = i break - success = self.chains[chain_id].remove_processor(processor) + + if chain_id is None: + success = True + else: + success = self.chains[chain_id].remove_processor(processor) if success: try: self.processors.pop(id) @@ -905,17 +1174,80 @@ def remove_processor(self, chain_id, processor, stop_engine=True, autoroute=True if stop_engine: self.stop_unused_engines() - if autoroute: - # Update chain routing (may have effected lots of chains) - for chain in self.chains.values(): - chain.rebuild_graph() - zynautoconnect.request_audio_connect() - zynautoconnect.request_midi_connect() - zynsigman.send_queued(zynsigman.S_CHAIN_MAN, self.SS_REMOVE_PROCESSOR) + if processor.eng_code == "MR": + self.chains[chain_id].zynmixer_proc = None + # Remove FX sends from existing chains + self.refresh_mixbus_sends() + + # Update chain routing (may have effected lots of chains) + for chain in self.chains.values(): + chain.rebuild_graph() + zynsigman.send_queued(zynsigman.S_CHAIN_MAN, self.SS_REMOVE_PROCESSOR) self.state_manager.end_busy("remove_processor") return success + def refresh_mixbus_sends(self): + mixbus_chain_ids = self.get_chain_ids_filtered(["mixbus"]) + for processor in self.processors.values(): + if processor.eng_code != "MI": + continue + + # Remove send controller pages + for page in list(processor.ctrl_screens_dict): + if page.startswith("send "): + processor.ctrl_screens_dict.pop(page) + # Create each send page + for send_idx, send_chain_id in enumerate(mixbus_chain_ids): + if send_chain_id == 0: # Exclude main mixbus + continue + send_chain = self.chains[send_chain_id] + level_symbol = f"send_{send_chain_id}_level" + mode_symbol = f"send_{send_chain_id}_mode" + name_prefix = f"send {send_idx + 1}" + # Generate a decent title for the ctrl_screen + ctrl_screen_title = name_prefix + if send_chain.title: + send_chain_name = send_chain.title + else: + send_chain_name = send_chain.get_processors("Audio Effect")[0].get_name() + if send_chain_name: + ctrl_screen_title += f" - {send_chain_name}" + # Create or update send controllers + if level_symbol in processor.controllers_dict: + processor.controllers_dict[level_symbol].name = f"{name_prefix} level" + processor.controllers_dict[level_symbol].short_name = f"{name_prefix} level" + processor.controllers_dict[mode_symbol].name = f"{name_prefix} mode" + processor.controllers_dict[mode_symbol].short_name = f"{name_prefix} mode" + else: + send = send_chain.zynmixer_proc.mixer_chan - 2 # FX returns start at 2, after main and aux + processor.controllers_dict[level_symbol] = zynthian_controller(processor.engine, level_symbol, { + 'name': f'{name_prefix} level', + 'value_max': 1.0, + 'value_default': 0.0, + 'value': processor.zynmixer.get_send_level(processor.mixer_chan, send), + 'processor': processor, + 'graph_path': ["send_level", send] + }) + processor.controllers_dict[mode_symbol] = zynthian_controller(processor.engine, mode_symbol, { + 'name': f'{name_prefix} mode', + 'value_max': 1, + 'value_default': 0, + 'value': processor.zynmixer.get_send_mode(processor.mixer_chan, send), + 'labels': ['post fader', 'pre fader'], + 'processor': processor, + 'graph_path': ["send_mode", send] + }) + # Add the control screen + processor.ctrl_screens_dict[ctrl_screen_title] = [processor.controllers_dict[level_symbol], processor.controllers_dict[mode_symbol]] + # Remove send controllers that doesn't exist anymore + for symbol in list(processor.controllers_dict): + if not symbol.startswith("send_"): + continue + s, c, t = symbol.split("_") + if int(c) not in mixbus_chain_ids: + del processor.controllers_dict[symbol] + def get_slot_count(self, chain_id, type=None): """Get the quantity of slots in a chain @@ -1025,8 +1357,7 @@ def stop_unused_engines(self): for eng_key in list(self.zyngines.keys()): if not self.zyngines[eng_key].processors: logging.debug(f"Stopping Unused Engine '{eng_key}' ...") - self.state_manager.set_busy_details( - f"stopping engine {self.zyngines[eng_key].get_name()}") + self.state_manager.set_busy_details(f"stopping engine {self.zyngines[eng_key].get_name()}") self.zyngines[eng_key].stop() del self.zyngines[eng_key] @@ -1035,8 +1366,7 @@ def stop_unused_jalv_engines(self): for eng_key in list(self.zyngines.keys()): if len(self.zyngines[eng_key].processors) == 0 and eng_key[0:3] == "JV/": logging.debug(f"Stopping Unused Jalv Engine '{eng_key}'...") - self.state_manager.set_busy_details( - f"stopping engine {self.zyngines[eng_key].get_name()}") + self.state_manager.set_busy_details(f"stopping engine {self.zyngines[eng_key].get_name()}") self.zyngines[eng_key].stop() del self.zyngines[eng_key] @@ -1058,8 +1388,7 @@ def filtered_engines_by_cat(self, etype, all=False): if (info["ENABLED"] or all) and hide_if_single_proc: result[eng_cat][eng_code] = info else: - logging.error( - f"Engine '{eng_code}' has invalid category '{eng_cat}'!") + logging.error(f"Engine '{eng_code}' has invalid category '{eng_cat}'!") # Remove empty categories for eng_cat in list(result.keys()): if not result[eng_cat]: @@ -1076,8 +1405,7 @@ def get_next_jackname(self, jackname, sanitize=True): # Jack, when listing ports, accepts regular expressions as the jack name. # So, for avoiding problems, jack names shouldn't contain regex characters. if sanitize: - jackname = re.sub("[\_]{2,}", "_", re.sub( - "[\s\'\*\(\)\[\]]", "_", jackname)) + jackname = re.sub("[\_]{2,}", "_", re.sub("[\s\'\*\(\)\[\]]", "_", jackname)) names = set() for processor in self.get_processors(): jn = processor.get_jackname() @@ -1122,7 +1450,7 @@ def get_state(self): """Get dictionary of chain slot states indexed by chain id""" state = {} - for chain_id in self.ordered_chain_ids: + for chain_id in self.chains: state[chain_id] = self.chains[chain_id].get_state() return state @@ -1162,35 +1490,31 @@ def set_state(self, state, engine_config, merge=False): chain_id = None chain_id = self.add_chain_from_state(chain_id, chain_state) if "slots" in chain_state: + slot = 0 for slot_state in chain_state["slots"]: # slot_state is a dict of proc_id:proc_type for procs in this slot - for index, proc_id in enumerate(slot_state): - eng_code = slot_state[proc_id] + for proc_id, eng_code in slot_state.items(): + if proc_id == str(zynthian_state_manager.MAIN_MIXBUS_ID): + continue # Do not replace main mixbus audio mixer processor try: eng_config = engine_config[eng_code] except: eng_config = None - # Use index to identify first proc in slot (add in series) - others are added in parallel - if index: - mode = CHAIN_MODE_PARALLEL - else: - mode = CHAIN_MODE_SERIES - self.add_processor(chain_id, eng_code, mode, proc_id=int(proc_id), - fast_refresh=False, eng_config=eng_config, midi_autolearn=False) - if "fader_pos" in chain_state and self.get_slot_count(chain_id, "Audio Effect") >= chain_state["fader_pos"]: - self.chains[chain_id].fader_pos = chain_state["fader_pos"] - else: - self.chains[chain_id].fader_pos = 0 - + # TODO: insert in correct slot, accounting for slot being relative to subchain type + # Use index to identify first proc in slot (add in series) + processor = self.add_processor(chain_id, eng_code, slot, proc_id=int(proc_id), eng_config=eng_config, midi_autolearn=False) + if processor: + slot = self.chains[chain_id].get_slot(processor) + slot += 1 if "zctrls" in chain_state: self.chains[chain_id].set_zctrls_state(chain_state["zctrls"]) - + self.rebuild_optimisation_cache() self.state_manager.end_busy("set_chain_state") def restore_presets(self): """Restore presets in active chain""" - for processor in self.get_processors(self.active_chain_id): + for processor in self.get_processors(self.active_chain.chain_id): processor.restore_preset() # ---------------------------------------------------------------------------- @@ -1308,7 +1632,7 @@ def remove_midi_learn_from_zctrl(self, zctrl, chain=True, abs=True, zynstep=None zynstep : remove zynstep MIDI learn. None for auto-delete (delete if it matches chain/abs MIDI learn). """ - logging.debug(f"(proccessor={zctrl.processor.id}, symbol={zctrl.symbol})") + # processor.id may not exist! logging.debug(f"(proccessor={zctrl.processor.id}, symbol={zctrl.symbol})") if zynstep is None: zynstep = not self.is_custom_zynstep_mapping(zctrl) @@ -1399,7 +1723,7 @@ def midi_control_change(self, zmip, midi_chan, cc_num, cc_val): # Handle bank change (CC0/32) # TODO: Validate and optimise bank change code if zynthian_gui_config.midi_bank_change: - for chain_id in self.midi_chan_2_chain_ids[midi_chan]: + for chain_id in self._midi_chan_2_chain_ids[midi_chan]: chain = self.chains[chain_id] if cc_num == 0: for processor in chain.get_processors(): @@ -1448,13 +1772,13 @@ def midi_control_change(self, zmip, midi_chan, cc_num, cc_val): try: # Channel-bond try: - key = (self.active_chain_id << 16) | key_low + key = (self.active_chain.chain_id << 16) | key_low zctrls1 = self.chain_midi_cc_binding[key] except: zctrls1 = [] # Channel-unbond try: - key = (self.active_chain_id << 16) | (0xff << 8) | cc_num + key = (self.active_chain.chain_id << 16) | (0xff << 8) | cc_num zctrls2 = self.chain_midi_cc_binding[key] except: zctrls2 = [] @@ -1471,7 +1795,7 @@ def clean_midi_learn(self, obj): """ if obj == None: - obj = self.active_chain_id + obj = self.active_chain.chain_id if obj == None: return @@ -1509,8 +1833,7 @@ def set_midi_prog_preset(self, midi_chan, midi_prog): continue changed |= processor.set_preset(midi_prog, True) except Exception as e: - logging.error( - f"Can't set preset for CH#{midi_chan}:PC#{midi_prog} => {e}") + logging.error(f"Can't set preset for CH#{midi_chan}:PC#{midi_prog} => {e}") return changed def set_midi_chan(self, chain_id, midi_chan): @@ -1533,12 +1856,12 @@ def set_midi_chan(self, chain_id, midi_chan): # ALL MIDI channels elif chain.midi_chan == 0xffff: midi_chans = list(range(MAX_NUM_MIDI_CHANS)) - # Remove from dictionary - for mc in midi_chans: - try: - self.midi_chan_2_chain_ids[mc].remove(chain_id) - except: - pass + + chain.set_midi_chan(midi_chan) + for mc in range(16): + if not self._midi_chan_2_chain_ids[mc]: + self.state_manager.zynseq.enable_channel(mc, False) + self.state_manager.zynseq.enable_channel(midi_chan, True, True) # Add new midi_chan(s) to dictionary if isinstance(midi_chan, int): @@ -1549,15 +1872,7 @@ def set_midi_chan(self, chain_id, midi_chan): # ALL MIDI channels elif midi_chan == 0xffff: midi_chans = list(range(MAX_NUM_MIDI_CHANS)) - # Add to dictionary - for mc in midi_chans: - try: - self.midi_chan_2_chain_ids[mc].append(chain_id) - # logging.debug(f"Adding chain ID {chain_id} to MIDI channel {mc}") - except: - pass - - chain.set_midi_chan(midi_chan) + self.rebuild_optimisation_cache() def get_free_midi_chans(self): """Get list of unused MIDI channels""" @@ -1598,36 +1913,10 @@ def get_num_chains_midi_chan(self, chan): """ try: - return len(self.midi_chan_2_chain_ids[chan]) + return len(self._midi_chan_2_chain_ids[chan]) except: return 0 - def get_free_mixer_chans(self): - """Get list of unused mixer channels""" - - free_chans = list(range(MAX_NUM_MIXER_CHANS)) - for chain in self.chains: - try: - free_chans.remove(self.chains[chain].mixer_chan) - except: - pass - return free_chans - - def get_next_free_mixer_chan(self, chan=0): - """Get next unused mixer channel - - chan : mixer channel to search from (Default: 0) - """ - - free_chans = self.get_free_mixer_chans() - for i in range(chan, MAX_NUM_MIXER_CHANS): - if i in free_chans: - return i - for i in range(chan): - if i in free_chans: - return i - raise Exception("No available free mixer channels!") - def is_free_zmop_index(self, zmop_index): """Get next unused zmop index """ @@ -1652,6 +1941,23 @@ def get_next_free_zmop_index(self): return i return None + def get_synth_chain(self, midi_chan): + """Get a chain in a given MIDI channel, preferably, a synth chain. + If several synth chains in the same MIDI channel, take the first one. + + chan : MIDI channel + Returns : Chain ID or None if not found + """ + # Try to find a Synth processor in the specified MIDI channel ... + for chain_id in self._midi_chan_2_chain_ids[midi_chan]: + processors = self.get_processors(chain_id, "MIDI Synth") + if len(processors) > 0: + return self.chains[chain_id] + # If not synth processors, return first chain in the MIDI channel + for chain_id in self._midi_chan_2_chain_ids[midi_chan]: + return self.chains[chain_id] + return None + def get_synth_processor(self, midi_chan): """Get a synth processor on MIDI channel If several synth chains in the same MIDI channel, take the first one. @@ -1661,12 +1967,12 @@ def get_synth_processor(self, midi_chan): Returns : Processor or None on failure """ # Try to find a Synth processor in the specified MIDI channel ... - for chain_id in self.midi_chan_2_chain_ids[midi_chan]: + for chain_id in self._midi_chan_2_chain_ids[midi_chan]: processors = self.get_processors(chain_id, "MIDI Synth") if len(processors) > 0: return processors[0] # If not synth processors, try other processor types... - for chain_id in self.midi_chan_2_chain_ids[midi_chan]: + for chain_id in self._midi_chan_2_chain_ids[midi_chan]: processors = self.get_processors(chain_id) if len(processors) > 0: return processors[0] diff --git a/zyngine/zynthian_controller.py b/zyngine/zynthian_controller.py index 03db7752d..4b48562ad 100644 --- a/zyngine/zynthian_controller.py +++ b/zyngine/zynthian_controller.py @@ -30,6 +30,7 @@ # Zynthian specific modules from zyncoder.zyncore import lib_zyncore +import zynautoconnect from zyngine.zynthian_signal_manager import zynsigman # ---------------------------------------------------------------------------- @@ -49,12 +50,13 @@ def __init__(self, engine, symbol, options=None): """ self.reset(engine, symbol, options) - def reset(self, engine, symbol, options=None): + def reset(self, engine, symbol, options=None, full=True): """ Reset to default settings engine - Engine object containing parameter to control symbol - String identifying the control options - Optional dictionary of controller {parameter:value} pairs + full - True to fully reset or False to retain core configuration and value """ self.engine = engine @@ -65,17 +67,18 @@ def reset(self, engine, symbol, options=None): self.group_name = "Ctrls" self.readonly = False - self.value = 0 # Absolute value of the control + if full: + self.value = 0 # Absolute value of the control self.value_default = None # Default value to use when reset control - self.value_min = None # Minimum value of control range - # Mid-point value of control range (used for toggle controls) - self.value_mid = None - self.value_max = None # Maximum value of control range - self.value_range = 0 # Span of permissible values - # Factor to scale each up/down nudge + self.value_min = None # Minimum value of control range + self.value_max = None # Maximum value of control range + self.value_range = 0 # Span of permissible values + self.value_mid = None # Mid-point value of control range (used for toggle controls) + # TODO: This is not set if configure is not called or options not passed - self.nudge_factor = None + self.nudge_factor = None # Factor to scale each up/down nudge self.nudge_factor_fine = None # Fine factor to scale + self.labels = None # List of discrete value labels self.ticks = None # List of discrete value ticks self.range_reversed = False # Flag if ticks order is reversed @@ -83,13 +86,13 @@ def reset(self, engine, symbol, options=None): self.is_trigger = False # True if control is one-shot trigger self.is_integer = True # True if control is Integer self.is_logarithmic = False # True if control uses logarithmic scale - self.is_path = False # True if the control is a file path (i.e. LV2's atom:Path) - self.path_file_types = None # List of supported file types - self.path_dir_names = None # List of directory names to look for files - self.path_preload = False # Flag for enable/disable file preload + if full: + self.is_path = False # True if the control is a file path (i.e. LV2's atom:Path) + self.path_file_types = None # List of supported file types + self.path_dir_names = None # List of directory names to look for files + self.path_preload = False # Flag for enable/disable file preload self.not_on_gui = False # True to hint to GUI to show control - self.display_priority = 0 # Hint of order in which to display control (higher comes first) - + self.display_priority = float("inf") # Hint of order in which to display control (higher comes first) self.is_dirty = True # True if control value changed since last UI update self.ignore_engine_fb_ts = None # Ignore next feedback value from the engine until this timestamp is over @@ -97,6 +100,9 @@ def reset(self, engine, symbol, options=None): self.midi_chan = None # MIDI channel to send CC messages from control self.midi_cc = None # MIDI CC number to send CC messages from control self.midi_autolearn = True # Auto-learn MIDI-CC based controllers + self.midi_cc_val1 = None # MIDI CC => controller value when CC value is 0 + self.midi_cc_val2 = None # MIDI CC => controller value when CC value is 127 + self.midi_cc_range = None # self.midi_cc_val2 - self.midi_cc_val1 self.midi_cc_momentary_switch = False self.midi_cc_mode = -1 # CC mode: -1=unknown, 0=abs, 1=rel1, 2=rel2, 3=rel3 self.midi_cc_mode_detecting = 0 # Used by CC mode detection algorithm @@ -135,8 +141,11 @@ def set_options(self, options): self.value = options['value'] if 'value_default' in options: self.value_default = options['value_default'] + if 'value' not in options: + self.value = self.value_default if 'value_min' in options: self.value_min = options['value_min'] + self.nudge_factor = None if 'value_max' in options: value_max = options['value_max'] # Selector @@ -152,9 +161,11 @@ def set_options(self, options): elif isinstance(value_max, int): self.value_max = value_max self.is_integer = True + self.nudge_factor = None elif isinstance(value_max, float): self.value_max = value_max self.is_integer = False + self.nudge_factor = None if 'labels' in options: self.labels = options['labels'] if 'ticks' in options: @@ -175,6 +186,8 @@ def set_options(self, options): self.is_logarithmic = options['is_logarithmic'] if 'is_path' in options: self.is_path = options['is_path'] + if self.is_path and not self.value: + self.value = "" # Ensure string value if 'path_file_types' in options: self.path_file_types = options['path_file_types'] if 'path_dir_names' in options: @@ -197,6 +210,8 @@ def set_options(self, options): self.display_priority = options['display_priority'] if 'envelope' in options and options['envelope'] is not None: self.envelope = options['envelope'] + if 'filter' in options and options['filter'] is not None: + self.filter = options['filter'] self._configure() def _configure(self): @@ -227,6 +242,8 @@ def _configure(self): self.value_min = 0 if self.value_max is None: self.value_max = 127 + elif len(self.labels) > 2: + self.is_toggle = False # Handle change of label length from 2 (which asserts toggle) # Generate ticks if needed ... if not self.ticks: @@ -244,12 +261,10 @@ def _configure(self): self.ticks.append(self.value_max) elif self.is_integer: for i in range(n): - self.ticks.append( - self.value_min + int(i * value_range / (n - 1))) + self.ticks.append(self.value_min + int(i * value_range / (n - 1))) else: for i in range(n): - self.ticks.append( - self.value_min + i * value_range / (n - 1)) + self.ticks.append(self.value_min + i * value_range / (n - 1)) # Calculate min, max if self.ticks[0] <= self.ticks[-1]: @@ -284,6 +299,14 @@ def _configure(self): if self.value_default is None: self.value_default = self.value + if self.midi_cc_val1 is None: + self.midi_cc_val1 = self.value_min + if self.midi_cc_val2 is None: + self.midi_cc_val2 = self.value_max + self.midi_cc_range = self.midi_cc_val2 - self.midi_cc_val1 + + self.is_dirty = True + if not self.nudge_factor: if self.is_logarithmic: self.nudge_factor = 0.01 # TODO: Use number of divisions @@ -422,6 +445,11 @@ def toggle(self): self.midi_cc_debounce_timer.start() else: self.set_value(value, send=True) + elif self.ticks: + idx = self.ticks.index(self.value) + 1 + if idx >= len(self.ticks): + idx = 0 + self.set_value(self.ticks[idx], send=True) def _set_value(self, val): if self.is_path: @@ -524,8 +552,6 @@ def get_value2index(self, val=None): if ndval < dval: dval = ndval index = i - else: - break return index else: return None @@ -553,12 +579,12 @@ def get_label2value(self, label): def get_ctrl_midi_val(self): try: - if self.value_range == 0: + if self.midi_cc_range == 0: return 0 elif self.is_logarithmic: - val = int(127 * math.log10((9 * self.value - (10 * self.value_min - self.value_max)) / self.value_range)) + val = int(127 * math.log10((9 * self.value - (10 * self.midi_cc_val1 - self.midi_cc_val2)) / self.midi_cc_range)) else: - val = min(127, int(127 * (self.value - self.value_min) / self.value_range)) + val = max(0, min(127, int(127 * (self.value - self.midi_cc_val1) / self.midi_cc_range))) except Exception as e: logging.error(e) val = 0 @@ -593,6 +619,11 @@ def get_state(self, full=True): state['value'] = self.value except: state['value'] = self.value + + if self.midi_cc_val1 is not None and self.midi_cc_val1 != self.value_min: + state['midi_cc_val1'] = self.midi_cc_val1 + if self.midi_cc_val2 is not None and self.midi_cc_val2 != self.value_max: + state['midi_cc_val2'] = self.midi_cc_val2 if self.midi_cc_momentary_switch: state['midi_cc_momentary_switch'] = self.midi_cc_momentary_switch if self.midi_cc_debounce: @@ -611,14 +642,11 @@ def midi_control_change(self, val, send=True): # CC mode not detected yet! if self.midi_cc_mode == -1: self.midi_cc_mode_detect(val) - # CC mode absolute if self.midi_cc_mode == 0: if self.range_reversed: val = 127 - val - if self.is_logarithmic: - value = self.value_min + self.value_range * (math.pow(10, val/127) - 1) / 9 - elif self.is_toggle: + if self.is_toggle: if self.midi_cc_momentary_switch: if val >= 64: self.toggle() @@ -629,7 +657,10 @@ def midi_control_change(self, val, send=True): else: value = self.value_min else: - value = self.value_min + val * self.value_range / 127 + if self.is_logarithmic: + value = self.midi_cc_val1 + self.midi_cc_range * (math.pow(10, val/127) - 1) / 9 + else: + value = self.midi_cc_val1 + val * self.midi_cc_range / 127 # Debounce if self.midi_cc_debounce: if self.midi_cc_debounce_timer: diff --git a/zyngine/zynthian_ctrldev_manager.py b/zyngine/zynthian_ctrldev_manager.py index 6429a83c5..6c0e38e8a 100644 --- a/zyngine/zynthian_ctrldev_manager.py +++ b/zyngine/zynthian_ctrldev_manager.py @@ -28,6 +28,7 @@ import glob import logging import importlib +import traceback from pathlib import Path # Zynthian specific modules @@ -35,6 +36,10 @@ from zyngui import zynthian_gui_config from zyncoder.zyncore import lib_zyncore +# TODO! GET TO WORK DYNAMIC MODULE LOADING +DRIVER_DEVELOPMENT = False +#DRIVER_DEVELOPMENT = True + # ------------------------------------------------------------------------------ # Zynthian Control Device Manager Class # ------------------------------------------------------------------------------ @@ -52,7 +57,7 @@ def __init__(self, state_manager): """ self.state_manager = state_manager - self.driver_classes = {} # Dictionary of driver classes indexed by module name + self.driver_classes = {} # Dictionary of driver classes indexed by module name self.available_drivers = {} # Dictionary of lists of available driver classes indexed by device ID self.drivers = {} # Map of device driver instances indexed by zmip self.disabled_devices = [] # List of device uid disabled from loading driver @@ -61,6 +66,7 @@ def __init__(self, state_manager): def update_available_drivers(self, reload_modules=False): """Update map of available driver names""" + self.available_drivers = {"*": []} if reload_modules: self.driver_classes = {} @@ -73,6 +79,8 @@ def update_available_drivers(self, reload_modules=False): #module = importlib.util.module_from_spec(spec) #spec.loader.exec_module(module) module = importlib.import_module(f"zyngine.ctrldev.{module_name}") + if reload_modules: + module = importlib.reload(module) except Exception as e: logging.error(f"Can't load ctrldev driver module '{module_name}' => {e}") continue @@ -83,7 +91,6 @@ def update_available_drivers(self, reload_modules=False): logging.error(f"Ctrldev driver class '{module_name}' not found in module '{module_name}'") # Regenerate available drivers dict - self.available_drivers = {"*": []} for module_name, driver_class in self.driver_classes.items(): for dev_id in driver_class.dev_ids: logging.info(f"Found ctrldev driver '{module_name}' for devices with ID '{dev_id}'") @@ -103,6 +110,9 @@ def load_driver(self, izmip, driver_name=None): if driver_name == "": return False + if DRIVER_DEVELOPMENT: + self.update_available_drivers() + # Get ID for the device attached to izmip dev_id = zynautoconnect.get_midi_in_devid(izmip) uid = zynautoconnect.get_midi_in_uid(izmip) @@ -146,15 +156,21 @@ def load_driver(self, izmip, driver_name=None): lib_zyncore.zmip_set_ui_midi_chans(izmip, driver.unroute_from_chains) else: lib_zyncore.zmip_set_ui_midi_chans(izmip, 0) - # Initialize the driver after creating the instance to enable driver MIDI handler - driver.init() # TODO: Why not call this in the driver _init_()? + # Enable driver's MIDI handler before initializing, so we can manage SysEX responses while initializing! self.drivers[izmip] = driver + # Initialize the driver + driver.init() if uid in self.disabled_devices: self.disabled_devices.remove(uid) logging.info(f"Loaded ctrldev driver '{driver_class.get_driver_name()}' for '{dev_id}'.") return True except Exception as e: + try: + self.drivers.pop(izmip) + except: + pass logging.error(f"Can't load ctrldev driver '{driver_class.get_driver_name()}' for '{dev_id}' => {e}") + logging.exception(traceback.format_exc()) return False def unload_driver(self, izmip, disable=False): @@ -169,8 +185,8 @@ def unload_driver(self, izmip, disable=False): if izmip in self.drivers: dev_id = zynautoconnect.get_midi_in_devid(izmip) uid = zynautoconnect.get_midi_in_uid(izmip) + # Drop the driver instance from the list driver = self.drivers[izmip] - # Drop from the list => Unload driver! self.drivers.pop(izmip) # Restore route to chains if driver.unroute_from_chains: diff --git a/zyngine/zynthian_engine.py b/zyngine/zynthian_engine.py index 2925df009..d8f59ebe3 100644 --- a/zyngine/zynthian_engine.py +++ b/zyngine/zynthian_engine.py @@ -191,6 +191,7 @@ def __init__(self, state_manager=None): self.preset_favs = None self.preset_favs_fpath = None self.show_favs_bank = True + self.monitors_dict = {} def reset(self): pass @@ -215,6 +216,9 @@ def config_remote_display(self): def refresh(self): pass + def get_monitors_dict(self): + return self.monitors_dict + # --------------------------------------------------------------------------- # OSC Management # --------------------------------------------------------------------------- @@ -642,9 +646,9 @@ def get_controllers_dict(self, processor): for symbol in list(processor.controllers_dict): zctrl = processor.controllers_dict[symbol] if symbol in symbols: - zctrl.reset(self, symbol) + zctrl.reset(self, symbol, full=False) else: - self.state_manager.chain_manager.remove_midi_learn_from_zctrl(zctrl) + self.state_manager.chain_manager.remove_midi_learn_from_zctrl(zctrl, chain=True, abs=True, zynstep=True) del processor.controllers_dict[symbol] # Regenerate / update controller dictionary for ctrl in self._ctrls: diff --git a/zyngine/zynthian_engine_aeolus.py b/zyngine/zynthian_engine_aeolus.py index 627d74971..246a07fb5 100644 --- a/zyngine/zynthian_engine_aeolus.py +++ b/zyngine/zynthian_engine_aeolus.py @@ -324,7 +324,6 @@ def start(self): chain = chain_manager.get_chain(chain_id) if proc_i: chain.audio_out = [] - chain.mixer_chan = None processor.refresh_controllers() proc_i += 1 diff --git a/zyngine/zynthian_engine_alsa_mixer.py b/zyngine/zynthian_engine_alsa_mixer.py index 4fbc32f3e..609d5753b 100644 --- a/zyngine/zynthian_engine_alsa_mixer.py +++ b/zyngine/zynthian_engine_alsa_mixer.py @@ -4,7 +4,7 @@ # # zynthian_engine implementation for Alsa Mixer # -# Copyright (C) 2015-2025 Fernando Moyano +# Copyright (C) 2015-2026 Fernando Moyano # # ****************************************************************************** # @@ -62,12 +62,6 @@ class zynthian_engine_alsa_mixer(zynthian_engine): device_overrides = {} - # --------------------------------------------------------------------------- - # Controllers & Screens - # --------------------------------------------------------------------------- - - _ctrl_screens = [] - # ---------------------------------------------------------------------------- # ZynAPI variables # ---------------------------------------------------------------------------- diff --git a/zyngine/zynthian_engine_audio_mixer.py b/zyngine/zynthian_engine_audio_mixer.py index aaa033ac1..db3b8079f 100644 --- a/zyngine/zynthian_engine_audio_mixer.py +++ b/zyngine/zynthian_engine_audio_mixer.py @@ -23,669 +23,214 @@ # # ******************************************************************** -import ctypes import logging +#import traceback from zyngine import zynthian_engine from zyngine import zynthian_controller from zyngine.zynthian_signal_manager import zynsigman +from zynlibs.zynmixer.zynmixer import SS_ZYNMIXER_SET_VALUE # ------------------------------------------------------------------------------- -# Zynmixer Library Wrapper +# zynmixer channel strip engine # ------------------------------------------------------------------------------- -class zynmixer(zynthian_engine): +class zynthian_engine_audio_mixer(zynthian_engine): - # Subsignals are defined inside each module. Here we define audio_mixer subsignals: - SS_ZCTRL_SET_VALUE = 1 + # Controller Screens + _ctrl_screens = [ + ['main', ['level', 'balance', 'mute', 'solo']], + ['options', ['mono', 'phase', 'ms', 'record']] + ] # Function to initialize library - def __init__(self): - super().__init__() - self.lib_zynmixer = ctypes.cdll.LoadLibrary(f"{self.ui_dir}/zynlibs/zynmixer/build/libzynmixer.so") - self.lib_zynmixer.init() + def __init__(self, state_manager): + super().__init__(state_manager) + self.type = "Audio Effect" + self.name = "Mixer" + self.nickname = "MI" + self.MAX_NUM_CHANNELS = 0 + zynsigman.register_queued(zynsigman.S_AUDIO_RECORDER, state_manager.audio_recorder.SS_AUDIO_RECORDER_STATE, self.audio_recorder_cb) - self.lib_zynmixer.setLevel.argtypes = [ctypes.c_uint8, ctypes.c_float] - self.lib_zynmixer.getLevel.argtypes = [ctypes.c_uint8] - self.lib_zynmixer.getLevel.restype = ctypes.c_float + def start(self): + pass - self.lib_zynmixer.setBalance.argtypes = [ - ctypes.c_uint8, ctypes.c_float] - self.lib_zynmixer.getBalance.argtypes = [ctypes.c_uint8] - self.lib_zynmixer.getBalance.restype = ctypes.c_float - - self.lib_zynmixer.setMute.argtypes = [ctypes.c_uint8, ctypes.c_uint8] - self.lib_zynmixer.toggleMute.argtypes = [ctypes.c_uint8] - self.lib_zynmixer.getMute.argtypes = [ctypes.c_uint8] - self.lib_zynmixer.getMute.restype = ctypes.c_uint8 - - self.lib_zynmixer.setMS.argtypes = [ctypes.c_uint8, ctypes.c_uint8] - self.lib_zynmixer.getMS.argtypes = [ctypes.c_uint8] - self.lib_zynmixer.getMS.restypes = ctypes.c_uint8 - - self.lib_zynmixer.setSolo.argtypes = [ctypes.c_uint8, ctypes.c_uint8] - self.lib_zynmixer.getSolo.argtypes = [ctypes.c_uint8] - self.lib_zynmixer.getSolo.restype = ctypes.c_uint8 - - self.lib_zynmixer.setMono.argtypes = [ctypes.c_uint8, ctypes.c_uint8] - self.lib_zynmixer.getMono.argtypes = [ctypes.c_uint8] - self.lib_zynmixer.getMono.restype = ctypes.c_uint8 - - self.lib_zynmixer.setPhase.argtypes = [ctypes.c_uint8, ctypes.c_uint8] - self.lib_zynmixer.getPhase.argtypes = [ctypes.c_uint8] - self.lib_zynmixer.getPhase.restype = ctypes.c_uint8 - - self.lib_zynmixer.setNormalise.argtypes = [ - ctypes.c_uint8, ctypes.c_uint8] - self.lib_zynmixer.getNormalise.argtypes = [ctypes.c_uint8] - self.lib_zynmixer.getNormalise.restype = ctypes.c_uint8 - - self.lib_zynmixer.reset.argtypes = [ctypes.c_uint8] - - self.lib_zynmixer.isChannelRouted.argtypes = [ctypes.c_uint8] - self.lib_zynmixer.isChannelRouted.restype = ctypes.c_uint8 - - self.lib_zynmixer.isChannelOutRouted.argtypes = [ctypes.c_uint8] - self.lib_zynmixer.isChannelOutRouted.restype = ctypes.c_uint8 - - self.lib_zynmixer.getDpm.argtypes = [ctypes.c_uint8, ctypes.c_uint8] - self.lib_zynmixer.getDpm.restype = ctypes.c_float - - self.lib_zynmixer.getDpmHold.argtypes = [ - ctypes.c_uint8, ctypes.c_uint8] - self.lib_zynmixer.getDpmHold.restype = ctypes.c_float - - self.lib_zynmixer.getDpmStates.argtypes = [ - ctypes.c_uint8, ctypes.c_uint8, ctypes.POINTER(ctypes.c_float)] - - self.lib_zynmixer.enableDpm.argtypes = [ - ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8] - - self.lib_zynmixer.getMaxChannels.restype = ctypes.c_uint8 - - self.MAX_NUM_CHANNELS = self.lib_zynmixer.getMaxChannels() - - # List of learned {cc:zctrl} indexed by learned MIDI channel - self.learned_cc = [dict() for x in range(16)] - - # List of {symbol:zctrl,...} indexed by mixer strip index - self.zctrls = [] - for i in range(self.MAX_NUM_CHANNELS): - strip_dict = { + def get_controllers_dict(self, processor): + if not processor.controllers_dict: + processor.controllers_dict = { 'level': zynthian_controller(self, 'level', { 'is_integer': False, 'value_max': 1.0, 'value_default': 0.8, - 'value': self.get_level(i), - 'nudge_factor': 0.005, - 'graph_path': [i, 'level'] + 'value': processor.zynmixer.get_level(processor.mixer_chan), + 'processor': processor }), 'balance': zynthian_controller(self, 'balance', { 'is_integer': False, 'value_min': -1.0, 'value_max': 1.0, 'value_default': 0.0, - 'value': self.get_balance(i), - 'nudge_factor': 0.01, - 'graph_path': [i, 'balance'] + 'value': processor.zynmixer.get_balance(processor.mixer_chan), + 'processor': processor }), 'mute': zynthian_controller(self, 'mute', { 'is_toggle': True, 'value_max': 1, 'value_default': 0, - 'value': self.get_mute(i), - 'graph_path': [i, 'mute'] + 'value': processor.zynmixer.get_mute(processor.mixer_chan), + 'processor': processor, + 'labels': ['off', 'on'] }), 'solo': zynthian_controller(self, 'solo', { 'is_toggle': True, 'value_max': 1, 'value_default': 0, - 'value': self.get_solo(i), - 'graph_path': [i, 'solo'] + 'value': processor.zynmixer.get_solo(processor.mixer_chan), + 'processor': processor, + 'labels': ['off', 'on'] }), 'mono': zynthian_controller(self, 'mono', { 'is_toggle': True, 'value_max': 1, 'value_default': 0, - 'value': self.get_mono(i), - 'graph_path': [i, 'mono'] + 'value': processor.zynmixer.get_mono(processor.mixer_chan), + 'processor': processor, + 'labels': ['off', 'on'] }), - 'ms': zynthian_controller(self, 'm+s', { + 'ms': zynthian_controller(self, 'ms', { 'is_toggle': True, 'value_max': 1, 'value_default': 0, - 'value': self.get_ms(i), - 'graph_path': [i, 'ms'] + 'value': processor.zynmixer.get_ms(processor.mixer_chan), + 'labels': ['off', 'on'], + 'processor': processor, + 'name': "M+S" }), 'phase': zynthian_controller(self, 'phase', { 'is_toggle': True, 'value_max': 1, 'value_default': 0, - 'value': self.get_phase(i), - 'graph_path': [i, 'phase'] + 'value': processor.zynmixer.get_phase(processor.mixer_chan), + 'processor': processor, + 'labels': ['off', 'on'] + }), + 'record': zynthian_controller(self, 'record', { + 'is_toggle': True, + 'value_max': 1, + 'value_default': 0, + 'value': 0, + 'processor': processor, + 'labels': ['off', 'on'] }) } - self.zctrls.append(strip_dict) - - self.midi_learn_zctrl = None - self.midi_learn_cb = None - - def get_controllers_dict(self, processor): - try: - return self.zctrls[processor.mixer_chan] - except: - return None - - def get_learned_cc(self, zctrl): - for chan in range(16): - for cc in self.learned_cc[chan]: - if zctrl == self.learned_cc[chan][cc]: - return [chan, cc] + if processor.chain.chain_id == 0: + processor.controllers_dict |= { + 'aux level': zynthian_controller(self, 'aux level', { + 'is_integer': False, + 'value_max': 1.0, + 'value_default': 0.8, + 'value': processor.zynmixer.get_level(1), + 'processor': processor + }), + 'aux balance': zynthian_controller(self, 'aux balance', { + 'is_integer': False, + 'value_min': -1.0, + 'value_max': 1.0, + 'value_default': 0.0, + 'value': processor.zynmixer.get_balance(1), + 'processor': processor + }), + 'aux mute': zynthian_controller(self, 'aux mute', { + 'is_toggle': True, + 'value_max': 1, + 'value_default': 0, + 'value': processor.zynmixer.get_mute(1), + 'processor': processor, + 'labels': ['off', 'on'] + }), + 'aux solo': zynthian_controller(self, 'aux solo', { + 'is_toggle': True, + 'value_max': 1, + 'value_default': 0, + 'value': processor.zynmixer.get_solo(1), + 'processor': processor, + 'labels': ['off', 'on'] + })} + return processor.controllers_dict + + def add_processor(self, processor): + self.processors.append(processor) + if processor.eng_code == "MR": + processor.zynmixer = self.state_manager.zynmixer_bus + processor.jackname = "zynmixer_bus" + if processor.chain_id: + # Aux Mixbus + processor.mixer_chan = self.state_manager.zynmixer_bus.add_strip() + send = self.state_manager.zynmixer_chan.add_send() + if processor.mixer_chan != send: + logging.warning("Aux Mixbus index mismatch") + processor.name = f"Aux Mixbus {self.state_manager.zynmixer_chan.get_send_count()}" + else: + # Main mixbus + processor.mixer_chan = 0 + processor.name = "Main Mixbus" + self._ctrl_screens = [ + ['main', ['level', 'balance', 'mute', 'solo']], + ['options', ['mono', 'phase', 'ms', 'record']], + ['aux', ['aux level', 'aux balance', 'aux mute', 'aux solo']] + ] + else: + # Normal audio mixer strip + processor.zynmixer = self.state_manager.zynmixer_chan + processor.jackname = "zynmixer_chan" + processor.mixer_chan = self.state_manager.zynmixer_chan.add_strip() + processor.name = f"Mixer Channel {processor.mixer_chan + 1}" + processor.refresh_controllers() + return + + def remove_processor(self, processor): + processor.zynmixer.set_mute(processor.mixer_chan, 1) + super().remove_processor(processor) + processor.zynmixer.remove_strip(processor.mixer_chan) + if processor.zynmixer == self.state_manager.zynmixer_bus: + send = processor.mixer_chan + self.state_manager.zynmixer_chan.remove_send(send) def send_controller_value(self, zctrl): try: - getattr(self, f'set_{zctrl.symbol}')( - zctrl.graph_path[0], zctrl.value, False) + if zctrl.symbol.startswith("send"): + getattr(zctrl.processor.zynmixer, f"set_{zctrl.graph_path[0]}")( + zctrl.processor.mixer_chan, zctrl.graph_path[1], zctrl.value) + elif zctrl.symbol == "solo": + if zctrl.processor.chain_id == 0: + for chain in self.state_manager.chain_manager.chains.values(): + if chain.zynmixer_proc: + chain.zynmixer_proc.controllers_dict["solo"].set_value(0) + zctrl.processor.controllers_dict["aux solo"].set_value(0) + else: + getattr(zctrl.processor.zynmixer, f'set_{zctrl.symbol}')(zctrl.processor.mixer_chan, zctrl.value) + glob_solo = self.state_manager.zynmixer_chan.get_global_solo() + self.state_manager.zynmixer_bus.set_solo(0, glob_solo) + elif zctrl.symbol.startswith("aux "): + getattr(zctrl.processor.zynmixer, f'set_{zctrl.symbol[4:]}')(1, zctrl.value) + else: + getattr(zctrl.processor.zynmixer, f'set_{zctrl.symbol}')(zctrl.processor.mixer_chan, zctrl.value) except Exception as e: - logging.warning(e) - - # Function to set fader level for a channel - # channel: Index of channel - # level: Fader value (0..+1) - # update: True for update controller - - def set_level(self, channel, level, update=True): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - self.lib_zynmixer.setLevel(channel, ctypes.c_float(level)) - if update: - self.zctrls[channel]['level'].set_value(level, False) - zynsigman.send(zynsigman.S_AUDIO_MIXER, self.SS_ZCTRL_SET_VALUE, - chan=channel, symbol="level", value=level) - - # Function to set balance for a channel - # channel: Index of channel - # balance: Balance value (-1..+1) - # update: True for update controller - def set_balance(self, channel, balance, update=True): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - self.lib_zynmixer.setBalance(channel, ctypes.c_float(balance)) - if update: - self.zctrls[channel]['balance'].set_value(balance, False) - zynsigman.send(zynsigman.S_AUDIO_MIXER, self.SS_ZCTRL_SET_VALUE, - chan=channel, symbol="balance", value=balance) - - # Function to get fader level for a channel - # channel: Index of channel - # returns: Fader level (0..+1) - def get_level(self, channel): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return self.lib_zynmixer.getLevel(channel) - - # Function to get balance for a channel - # channel: Index of channel - # returns: Balance value (-1..+1) - def get_balance(self, channel): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return self.lib_zynmixer.getBalance(channel) - - # Function to set mute for a channel - # channel: Index of channel - # mute: Mute state (True to mute) - # update: True for update controller - def set_mute(self, channel, mute, update=False): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - self.lib_zynmixer.setMute(channel, mute) - if update: - self.zctrls[channel]['mute'].set_value(mute, False) - zynsigman.send(zynsigman.S_AUDIO_MIXER, self.SS_ZCTRL_SET_VALUE, - chan=channel, symbol="mute", value=mute) - - # Function to get mute for a channel - # channel: Index of channel - # returns: Mute state (True if muted) - def get_mute(self, channel): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return self.lib_zynmixer.getMute(channel) - - # Function to toggle mute of a channel - # channel: Index of channel - # update: True for update controller - def toggle_mute(self, channel, update=False): - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - self.lib_zynmixer.toggleMute(channel) - mute = self.lib_zynmixer.getMute(channel) - if update: - self.zctrls[channel]['mute'].set_value(mute, False) - zynsigman.send(zynsigman.S_AUDIO_MIXER, self.SS_ZCTRL_SET_VALUE, - chan=channel, symbol="mute", value=mute) - - # Function to set phase reversal for a channel - # channel: Index of channel - # phase: Phase reversal state (True to reverse) - # update: True for update controller - def set_phase(self, channel, phase, update=True): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - self.lib_zynmixer.setPhase(channel, phase) - if update: - self.zctrls[channel]['phase'].set_value(phase, False) - zynsigman.send(zynsigman.S_AUDIO_MIXER, self.SS_ZCTRL_SET_VALUE, - chan=channel, symbol="phase", value=phase) - - # Function to get phase reversal for a channel - # channel: Index of channel - # returns: Phase reversal state (True if phase reversed) - def get_phase(self, channel): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return self.lib_zynmixer.getPhase(channel) - - # Function to toggle phase reversal of a channel - # channel: Index of channel - # update: True for update controller - def toggle_phase(self, channel, update=True): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - self.lib_zynmixer.togglePhase(channel) - phase = self.lib_zynmixer.getPhase(channel) - if update: - self.zctrls[channel]['phase'].set_value(phase, False) - zynsigman.send(zynsigman.S_AUDIO_MIXER, self.SS_ZCTRL_SET_VALUE, - chan=channel, symbol="phase", value=phase) - - # Function to set solo for a channel - # channel: Index of channel - # solo: Solo state (True to solo) - # update: True for update controller - def set_solo(self, channel, solo, update=True): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - self.lib_zynmixer.setSolo(channel, solo) - if update: - self.zctrls[channel]['solo'].set_value(solo, False) - zynsigman.send(zynsigman.S_AUDIO_MIXER, self.SS_ZCTRL_SET_VALUE, - chan=channel, symbol="solo", value=solo) - - if channel == self.MAX_NUM_CHANNELS - 1: - # Main strip solo clears all chain solo - for i in range(0, self.MAX_NUM_CHANNELS - 2): - self.zctrls[i]['solo'].set_value(solo, 0) - zynsigman.send( - zynsigman.S_AUDIO_MIXER, self.SS_ZCTRL_SET_VALUE, chan=i, symbol="solo", value=0) - - # Function to get solo for a channel - # channel: Index of channel - # returns: Solo state (True if solo) - def get_solo(self, channel): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return self.lib_zynmixer.getSolo(channel) == 1 - - # Function to toggle mute of a channel - # channel: Index of channel - # update: True for update controller - def toggle_solo(self, channel, update=True): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - if self.get_solo(channel): - self.set_solo(channel, False) - else: - self.set_solo(channel, True) - - # Function to mono a channel - # channel: Index of channel - # mono: Mono state (True to solo) - # update: True for update controller - def set_mono(self, channel, mono, update=True): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - self.lib_zynmixer.setMono(channel, mono) - if update: - self.zctrls[channel]['mono'].set_value(mono, False) - zynsigman.send(zynsigman.S_AUDIO_MIXER, self.SS_ZCTRL_SET_VALUE, - chan=channel, symbol="mono", value=mono) - - # Function to get mono for a channel - # channel: Index of channel - # returns: Mono state (True if mono) - def get_mono(self, channel): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return self.lib_zynmixer.getMono(channel) == 1 - - # Function to get all mono - # returns: List of mono states (True if mono) - def get_all_monos(self): - monos = (ctypes.c_bool * (self.MAX_NUM_CHANNELS))() - self.lib_zynmixer.getAllMono(monos) - result = [] - for i in monos: - result.append(i) - return result - - # Function to toggle mono of a channel - # channel: Index of channel - # update: True for update controller - def toggle_mono(self, channel, update=True): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - if self.get_mono(channel): - self.set_mono(channel, False) - else: - self.set_mono(channel, True) - if update: - self.zctrls[channel]['mono'].set_value( - self.lib_zynmixer.getMono(channel), False) - - # Function to enable M+S mode - # channel: Index of channel - # enable: M+S state (True to enable) - # update: True for update controller - - def set_ms(self, channel, enable, update=True): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - self.lib_zynmixer.setMS(channel, enable) - if update: - self.zctrls[channel]['ms'].set_value(enable, False) - zynsigman.send(zynsigman.S_AUDIO_MIXER, self.SS_ZCTRL_SET_VALUE, - chan=channel, symbol="ms", value=enable) - - # Function to get M+S mode - # channel: Index of channel - # returns: M+S mode (True if enabled) - def get_ms(self, channel): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return self.lib_zynmixer.getMS(channel) == 1 - - # Function to toggle M+S mode - # channel: Index of channel - # update: True for update controller - def toggle_ms(self, channel, update=True): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - if self.get_ms(channel): - self.set_ms(channel, False) - else: - self.set_ms(channel, True) - if update: - self.zctrls[channel]['ms'].set_value( - self.lib_zynmixer.getMS(channel), False) - - # Function to set internal normalisation of a channel when its direct output is not routed - # channel: Index of channel - # enable: True to enable internal normalisation - def normalise(self, channel, enable): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS - 1: - return # Don't allow normalisation of main mixbus (to itself) - self.lib_zynmixer.setNormalise(channel, enable) - - # Function to get the internal normalisation state of s channel - # channel: Index of channel - # enable: True to enable internal normalisation - # update: True for update controller - def is_normalised(self, channel): - if channel is None: - return False - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return self.lib_zynmixer.getNormalise(channel) - - # Function to check if channel has audio routed to its input - # channel: Index of channel - # returns: True if routed - def is_channel_routed(self, channel): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return (self.lib_zynmixer.isChannelRouted(channel) != 0) - - # Function to check if channel output is routed - # channel: Index of channel - # returns: True if routed - def is_channel_out_routed(self, channel): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return (self.lib_zynmixer.isChannelOutRouted(channel) != 0) - - # Function to get peak programme level for a channel - # channel: Index of channel - # leg: 0 for A-leg (left), 1 for B-leg (right) - # returns: Peak programme level - def get_dpm(self, channel, leg): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return self.lib_zynmixer.getDpm(channel, leg) - - # Function to get peak programme hold level for a channel - # channel: Index of channel - # leg: 0 for A-leg (left), 1 for B-leg (right) - # returns: Peak programme hold level - def get_dpm_holds(self, channel, leg): - if channel is None: - return - if channel >= self.MAX_NUM_CHANNELS: - channel = self.MAX_NUM_CHANNELS - 1 - return self.lib_zynmixer.getDpmHold(channel, leg) - - # Function to get the dpm states for a set of channels - # start: Index of first channel - # end: Index of last channel - # returns: List of tuples containing (dpm_a, dpm_b, hold_a, hold_b, mono) - def get_dpm_states(self, start, end): - state = (ctypes.c_float * (5 * (end - start + 1)))() - self.lib_zynmixer.getDpmStates(start, end, state) - result = [] - offset = 0 - for channel in range(start, end + 1): - l = [] - for i in range(4): - l.append(state[offset]) - offset += 1 - l.append(state[offset] != 0.0) - offset += 1 - result.append(l) - return result - - # Function to enable or disable digital peak meters - # start: First mixer channel - # end: Last mixer channel - # enable: True to enable - def enable_dpm(self, start, end, enable): - if start is None or end is None: - return - self.lib_zynmixer.enableDpm(start, end, int(enable)) - - # Function to add OSC client registration - # client: IP address of OSC client - def add_osc_client(self, client): - return self.lib_zynmixer.addOscClient(ctypes.c_char_p(client.encode('utf-8'))) - - # Function to remove OSC client registration - # client: IP address of OSC client - def remove_osc_client(self, client): - self.lib_zynmixer.removeOscClient( - ctypes.c_char_p(client.encode('utf-8'))) - - # -------------------------------------------------------------------------- - # State management (for snapshots) - # -------------------------------------------------------------------------- - - def reset(self, strip): - """Reset mixer strip to default values - - strip : Index of mixer strip - """ - if not isinstance(strip, int): - return - if strip >= self.MAX_NUM_CHANNELS: - strip = self.MAX_NUM_CHANNELS - 1 - self.zctrls[strip]['level'].reset_value() - self.zctrls[strip]['balance'].reset_value() - self.zctrls[strip]['mute'].reset_value() - self.zctrls[strip]['mono'].reset_value() - self.zctrls[strip]['solo'].reset_value() - self.zctrls[strip]['phase'].reset_value() - # for symbol in self.zctrls[strip]: - # self.zctrls[strip][symbol].midi_unlearn() - - # Reset mixer to default state - def reset_state(self): - for channel in range(self.MAX_NUM_CHANNELS): - self.reset(channel) - - def get_state(self, full=True): - """Get mixer state as list of controller state dictionaries - - full : True to get state of all parameters or false for off-default values - Returns : List of dictionaries describing parameter states - """ - state = {} - for chan in range(self.MAX_NUM_CHANNELS): - key = 'chan_{:02d}'.format(chan) - chan_state = {} - for symbol in self.zctrls[chan]: - zctrl = self.zctrls[chan][symbol] - value = zctrl.value - if zctrl.is_toggle: - value |= (zctrl.midi_cc_momentary_switch << 1) - if value != zctrl.value_default: - chan_state[zctrl.symbol] = value - if chan_state: - state[key] = chan_state - state["midi_learn"] = {} - for chan in range(16): - for cc, zctrl in self.learned_cc[chan].items(): - state["midi_learn"][f"{chan},{cc}"] = zctrl.graph_path - return state - - def set_state(self, state, full=True): - """Set mixer state - - state : List of mixer channels containing dictionary of each state value - full : True to reset parameters omitted from state - """ - - for chan, zctrls in enumerate(self.zctrls): - key = 'chan_{:02d}'.format(chan) - for symbol, zctrl in zctrls.items(): - try: - if zctrl.is_toggle: - zctrl.set_value(state[key][symbol] & 1, True) - zctrl.midi_cc_momentary_switch = state[key][symbol] >> 1 - else: - zctrl.set_value(state[key][symbol], True) - except: - if full: - zctrl.reset_value() - if "midi_learn" in state: - # state["midi_learn"][f"{chan},{cc}"] = zctrl.graph_path - self.midi_unlearn_all() - for ml, graph_path in state["midi_learn"].items(): - try: - chan, cc = ml.split(',') - zctrl = self.zctrls[graph_path[0]][graph_path[1]] - self.learned_cc[int(chan)][int(cc)] = zctrl - except Exception as e: - logging.warning( - f"Failed to restore mixer midi learn: {ml} => {graph_path} ({e})") - - # -------------------------------------------------------------------------- - # MIDI Learn - # -------------------------------------------------------------------------- - - def midi_control_change(self, chan, ccnum, val): - if self.midi_learn_zctrl and self.midi_learn_zctrl != True: - for midi_chan in range(16): - for midi_cc in self.learned_cc[midi_chan]: - if self.learned_cc[midi_chan][midi_cc] == self.midi_learn_zctrl: - self.learned_cc[midi_chan].pop(midi_cc) - break - self.learned_cc[chan][ccnum] = self.midi_learn_zctrl - self.disable_midi_learn() - if self.midi_learn_cb: - self.midi_learn_cb() - else: - for ch in range(16): - try: - self.learned_cc[ch][ccnum].midi_control_change(val) - break - except: - pass - - def midi_unlearn(self, zctrl): - for chan, learned in enumerate(self.learned_cc): - for cc, ctrl in learned.items(): - if ctrl == zctrl: - self.learned_cc[chan].pop(cc) - return - - def midi_unlearn_chan(self, chan): - for zctrl in self.zctrls[chan].values(): - self.midi_unlearn(zctrl) - - def midi_unlearn_all(self, not_used=None): - self.learned_cc = [dict() for x in range(16)] - - def enable_midi_learn(self, zctrl): - self.midi_learn_zctrl = zctrl - - def disable_midi_learn(self): - self.midi_learn_zctrl = None - - def set_midi_learn_cb(self, cb): - self.midi_learn_cb = cb + logging.error(e) + #logging.exception(traceback.format_exc()) + + def get_path(self, processor): + return processor.name + if processor.chain_id: + if processor.eng_code == "MR": + return f"Aux Mixbus {processor.chain_id}" + else: + return f"Mixer Channel {processor.chain_id}" + return f"Main Mixbus" + + def audio_recorder_cb(self, state): + for processor in self.processors: + processor.controllers_dict["record"].set_readonly(state) # ------------------------------------------------------------------------------- diff --git a/zyngine/zynthian_engine_clippy.py b/zyngine/zynthian_engine_clippy.py new file mode 100644 index 000000000..977bb8b77 --- /dev/null +++ b/zyngine/zynthian_engine_clippy.py @@ -0,0 +1,571 @@ +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian Engine (zynthian_engine_clippy) +# +# zynthian_engine implementation for clip launcher +# +# Copyright (C) 2015-2025 Fernando Moyano +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import os +import logging +import re +from threading import Timer +import ctypes +from time import sleep + +from zynlibs.zynseq import zynseq +from zyngine.zynthian_signal_manager import zynsigman + +from . import zynthian_engine +from . import zynthian_controller + +import zynautoconnect + + +# ------------------------------------------------------------------------------ +# Clippy Engine Class +# ------------------------------------------------------------------------------ + +MAX_BEATS = 64 # Maximum quantity of beats in a clip +MAX_DURATION = 30 # Maximum audio duration to warp, in seconds +MAX_STORAGE = 500 * 1000 * 1024 # Maximum storage for temporary files + +class zynthian_engine_clippy(zynthian_engine): + + # --------------------------------------------------------------------------- + # Initialization + # --------------------------------------------------------------------------- + + def __init__(self, state_manager=None, jackname=None): + super().__init__(state_manager) + self.zynseq = state_manager.zynseq + self.libseq = self.zynseq.libseq + self.libclippy = ctypes.cdll.LoadLibrary("/zynthian/zynthian-ui/zynlibs/zynclippy/build/libzynclippy.so") + self.libclippy.init() + self.libclippy.getGain.restype = ctypes.c_float + self.libclippy.getJackname.restype = ctypes.c_char_p + self.zynseq.clippy = self + + self.name = "Clippy" + self.nickname = "CL" + self.type = "Audio Generator" + self.options["replace"] = False + + self.jackname = self.libclippy.getJackname().decode("utf-8") + self._ctrls = [] + self._ctrl_screens = [] + + self.selected_proc = None + self.selected_phrase = None + self.selected_note = None + self.tmp_file_idx = 0 + + self.tempo_timer = None + self.crop_timer = None + self.tempo_mutex = False + self.crop_cb_timer = None + self.samplerate = zynautoconnect.get_jackd_samplerate() + zynsigman.register_queued(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_TEMPO, self.start_tempo_timer) + + # --------------------------------------------------------------------------- + # Subproccess Management & IPC + # --------------------------------------------------------------------------- + + def stop(self): + logging.info("Stopping Engine " + self.name) + self.zynseq.clippy = None + zynsigman.unregister(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_TEMPO, self.start_tempo_timer) + self.libclippy.end() + + def set_phrase(self, processor, phrase): + """ Select the phrase for control, etc""" + + self.selected_proc = processor + self.selected_phrase = phrase + self.monitors_dict = {} + note = phrase + 1 + try: + if processor.controllers_dict[f"file {note}"].value: + self._ctrl_screens = [ + ["Clip", [f"file {note}", f"crop_start {note}", f"crop_end {note}", f"zoom {note}"]], + ["Control", [f"gain {note}", f"warp {note}", f"beats {note}", f"mode {note}"]] + ] + for symbol in ["zoom", "crop_start", "crop_end"]: + self.monitors_dict[symbol] = processor.controllers_dict[f"{symbol} {note}"].value + else: + self._ctrl_screens = [["Clip", [f"file {note}"]]] + # Set processor name for display + processor.preset_name = processor.controllers_dict[f"file {note}"].value.split("/")[-1] + except: + self._ctrl_screens = [["Clip", ["file"]]] + processor.preset_name = "" + self.selected_note = 0 + processor.init_ctrl_screens() + + def send_controller_value(self, zctrl): + try: + note = int(zctrl.symbol.split(" ")[1]) + phrase = note - 1 + #beats_zctrl = zctrl.processor.controllers_dict[f"beats {note}"] + #mode_zctrl = zctrl.processor.controllers_dict[f"mode {note}"] + except Exception as e: + logging.error(f"Can't determine sample index {zctrl.symbol} => {e}") + return + if zctrl.symbol.startswith("file"): + self.set_file(zctrl.processor, phrase, True) + elif zctrl.symbol.startswith("warp"): + self.set_file(zctrl.processor, phrase) + elif zctrl.symbol.startswith("mode"): + self.set_mode(phrase, zctrl.processor.midi_chan, zctrl.value) + elif zctrl.symbol.startswith("crop_start"): + zctrl_crop_end = zctrl.processor.controllers_dict[zctrl.symbol.replace("start", "end")] + if zctrl.value >= zctrl_crop_end.value: + zctrl.set_value(zctrl.crop_end.value - 1) + return + self.monitors_dict["crop_start"] = zctrl.value + self.start_crop_timer(zctrl.processor, phrase) + return + elif zctrl.symbol.startswith("crop_end"): + zctrl_crop_start = zctrl.processor.controllers_dict[zctrl.symbol.replace("end", "start")] + if zctrl.value <= zctrl_crop_start.value: + zctrl.set_value(zctrl_crop_start.value + 1) + return + self.monitors_dict["crop_end"] = zctrl.value + self.start_crop_timer(zctrl.processor, phrase) + return + elif zctrl.symbol.startswith("gain"): + try: + self.libclippy.setGain(zctrl.processor.midi_chan - 16, phrase, ctypes.c_float(zctrl.value)) + except Exception as e: + logging.warning(e) + return + elif zctrl.symbol.startswith("zoom"): + self.update_nudge(zctrl.processor, note) + self.monitors_dict["zoom"] = zctrl.value + return + + def update_nudge(self, processor, note): + zctrl_crop_start = processor.controllers_dict[f"crop_start {note}"] + zctrl_crop_end = processor.controllers_dict[f"crop_end {note}"] + zctrl_zoom = processor.controllers_dict[f"zoom {note}"] + frames = zctrl_crop_end.value_max + nudge_factor = max(1, frames // (zctrl_zoom.value * 100)) + zctrl_crop_start.nudge_factor = nudge_factor + zctrl_crop_end.nudge_factor = nudge_factor + + """ Set play mode + + phrase - Index of phrase + chan - MIDI channel + mode - play mode [0=disabled, 1=loop, 2..25=play 1..24 times] + """ + def set_mode(self, phrase, chan, mode): + match mode: + case 0: + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "repeat", 0) + case 1: + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "mode", 0x01) + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "followAction", zynseq.FOLLOW_ACTION_RELATIVE) + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "followParam", 0) + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "repeat", 1) + case _: + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "mode", 0x01) + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "followAction", zynseq.FOLLOW_ACTION_NONE) + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "followParam", 0) + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "repeat", mode - 1) + + def insert_phrase(self, phrase): + """ Inserts a new empty phrase immediately before the indexed phrase + + phrase: Index of phrase to insert new phrase before + """ + + for processor in self.processors: + self.libclippy.insertClip(processor.midi_chan - 16, phrase) + for idx in range(self.zynseq.phrases, phrase, -1): + try: + for symbol in ("file", "crop_start", "crop_end", "zoom", "gain", "warp", "beats", "mode"): + processor.controllers_dict[f"{symbol} {idx}"] = processor.controllers_dict[f"{symbol} {idx - 1}"] + processor.controllers_dict[f"{symbol} {idx}"].symbol = f"{symbol} {idx}" + except: + pass # Ignore unpopulated phrases + self.add_controllers(processor, phrase + 1) + processor.controllers_dict[f"mode {idx}"].set_value(0) + + def remove_phrase(self, phrase): + """ Remove a phrase + + phrase: Index of phrase to remove + """ + + for processor in self.processors: + self.libclippy.removeClip(processor.midi_chan - 16, phrase) + self.remove_tmp_file(processor, phrase) + for idx in range(phrase + 1, self.zynseq.phrases + 1): + try: + for symbol in ("file", "crop_start", "crop_end", "zoom", "gain", "warp", "beats", "mode"): + if idx < self.zynseq.phrases: + processor.controllers_dict[f"{symbol} {idx}"] = processor.controllers_dict[f"{symbol} {idx + 1}"] + processor.controllers_dict[f"{symbol} {idx}"].symbol = f"{symbol} {idx}" + else: + del processor.controllers_dict[f"{symbol} {idx}"] + except: + pass # Ignore unpopulated phrases + + def swap_phrase(self, phrase1, phrase2): + """ Swap two phrases + + phrase1 Index of first phrase + phrase1 Index of second phrase + """ + + for processor in self.processors: + self.libclippy.swapClip(processor.midi_chan - 16, phrase1, phrase2) + try: + for symbol in ("file", "crop_start", "crop_end", "zoom", "gain", "warp", "beats", "mode"): + a = processor.controllers_dict[f"{symbol} {phrase1 + 1}"] + processor.controllers_dict[f"{symbol} {phrase1 + 1}"] = processor.controllers_dict[f"{symbol} {phrase2 + 1}"] + processor.controllers_dict[f"{symbol} {phrase1 + 2}"] = a + processor.controllers_dict[f"{symbol} {phrase1 + 1}"].symbol = f"{symbol} {phrase1 + 1}" + processor.controllers_dict[f"{symbol} {phrase1 + 2}"].symbol = f"{symbol} {phrase1 + 2}" + except: + pass # Ignore unpopulated phrases + + def set_file(self, processor, phrase, reset=False): + """ Loads a file into a clip. SRC and warp to new file if necessary + + processor: Clippy processor + phrase: Phrase index + reset: True to reset crop parameters + """ + + note = phrase + 1 + file_zctrl = processor.controllers_dict[f"file {note}"] + path = file_zctrl.value + if reset: + self.remove_tmp_file(processor, phrase) + + if path: + sr = self.libclippy.getFileSamplerate(bytes(path, "utf-8")) + frames = self.libclippy.getFileFrames(bytes(path, "utf-8")) + self.update_controllers(processor, note, frames, reset) + ratio = 1.0 + write_file = (sr != self.samplerate) + processor.preset_name = path.split("/")[-1] # Used for display purpose only + + # Try to determine tempo from filename + filename = os.path.basename(path) + tempo = self.zynseq.get_sequence_param(self.zynseq.scene, phrase, zynseq.PHRASE_CHANNEL, "tempo") + if not tempo: + tempo = self.zynseq.libseq.getTempo() + regptn = r"(\d+)\s*(?=bpm|BPM)" + matches = re.findall(regptn, filename) + try: + file_tempo = float(matches[0]) + except: + file_tempo = tempo + + # Configure clip with required beats to play whole file at this tempo + try: + warp_zctrl = processor.controllers_dict[f"warp {note}"] + beats_zctrl = processor.controllers_dict[f"beats {note}"] + crop_start_zctrl = processor.controllers_dict[f"crop_start {note}"] + crop_end_zctrl = processor.controllers_dict[f"crop_end {note}"] + if reset: + beats_zctrl.value = 0 + warp_zctrl.value = 1 + crop_start_zctrl.value = 0 + crop_end_zctrl.value = frames + + beats_value = beats_zctrl.value + warp_value = warp_zctrl.value + crop_start = crop_start_zctrl.value + crop_end = crop_end_zctrl.value + + duration = (crop_end - crop_start) / sr + beats_per_bar = self.zynseq.get_sequence_param(self.zynseq.scene, phrase, zynseq.PHRASE_CHANNEL, "bpb") + if beats_per_bar < 1: + beats_per_bar = self.zynseq.bpb + beats = duration * file_tempo / 60 + bars = round(beats / beats_per_bar) + + if beats_value: + whole_beats = beats_value + else: + whole_beats = bars * beats_per_bar + can_warp = whole_beats <= MAX_BEATS and duration <= MAX_DURATION + factor = (whole_beats * file_tempo) / (beats * tempo) + + # File BPM matches current BPM + if abs(factor - 1.0) < 0.0001: + bpm_match = True + else: + bpm_match = False + + if warp_value and not bpm_match and can_warp: + # Warp audio to fit sequence length, only if short enough to avoid slow warp + ratio = factor + write_file = True + + try: + dst_path = file_zctrl.path + except: + dst_path = f"/tmp/clippy_{self.tmp_file_idx:04x}.wav" + self.tmp_file_idx += 1 + if write_file and self.libclippy.copyFile(bytes(path, "utf-8"), bytes(dst_path, "utf-8"), 2, ctypes.c_float(ratio), crop_start, crop_end) == 0: + path = dst_path + file_zctrl.path = path + #zctrl_crop_end.value_max = zctrl_crop_end.value_range = self.libclippy.getFileFrames(bytes(dst_path, "utf-8")) + if f"beats {note}" in processor.controllers_dict: + if can_warp: + beats_zctrl.value = whole_beats + beats_zctrl.set_readonly(warp_zctrl.value != 0) + else: + beats_zctrl.value = 0 + warp_zctrl.value = 0 + + # Setup zynseq sequence + new_note = self.libclippy.loadClip(processor.midi_chan - 16, note, bytes(path, "utf-8")) + if note != new_note: + logging.warning(f"Clippy error - wrong note {note}/{new_note} assigned!") + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, processor.midi_chan, "name", os.path.splitext(filename)[0]) + self.set_mode(phrase, processor.midi_chan, 1) # Default repeat + if whole_beats < 1: + whole_beats = beats_per_bar + self.libseq.setSequenceLength(self.zynseq.scene, phrase, processor.midi_chan, whole_beats * self.zynseq.PPQN) + if phrase == self.selected_phrase: + self.set_phrase(processor, phrase) + + except Exception as e: + logging.error(f"Can't setup sequencer for clip {note} => {e}") + else: + self.libseq.setPlayState(self.zynseq.scene, phrase, processor.midi_chan, zynseq.SEQ_STOPPED) + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, processor.midi_chan, "repeat", 0) + self.libseq.updateSequenceInfo() + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, processor.midi_chan, "name", "") + self.libclippy.unloadClip(processor.midi_chan - 16, note) + self._ctrl_screens = [["Clip", [f"file {note}"]]] + + processor.init_ctrl_screens() + + def start_crop_timer(self, processor, phrase): + #TODO: This can cause a lot of file writing + if self.crop_timer: + self.crop_timer.cancel() + self.crop_timer = Timer(0.5, self.crop_timer_cb, args=(processor, phrase)) + self.crop_timer.start() + + def crop_timer_cb(self, processor, phrase): + if self.crop_timer: + self.crop_timer.cancel() + self.crop_timer = None + self.set_file(processor, phrase) + + def start_tempo_timer(self, tempo=None): + #TODO: This crashes with double free at high tempo + if self.tempo_timer: + self.tempo_timer.cancel() + self.tempo_timer = Timer(0.5, self.tempo_timer_cb) + self.tempo_timer.start() + + def tempo_timer_cb(self): + if self.tempo_timer: + self.tempo_timer.cancel() + self.tempo_timer = None + while self.tempo_mutex: + sleep(0.001) + self.tempo_mutex = True + for processor in self.processors: + for phrase in range(self.zynseq.phrases): + symbol = f"warp {phrase + 1}" + try: + if processor.controllers_dict.get(symbol).value: + self.set_file(processor, phrase) + except: + continue + self.tempo_mutex = False + + def add_controllers(self, processor, note): + """ Adds a controllers to processor + + processor: Clippy processor object + note: MIDI note (clip id) + """ + + # Add default controllers for each phrase + zctrls = {} + zctrls[f"file {note}"] = zynthian_controller(self, f"file {note}", { + "name": "file", + "processor": processor, + "is_path": True, + "path_file_types": ["wav", "ogg", "mp3", "flac", "aac"], + }) + zctrls[f"warp {note}"] = zynthian_controller(self, f"warp {note}", { + "name": "warp", + "processor": processor, + "is_toggle": True, + "labels": ["off", "on"], + "value": "on" + }) + zctrls[f"beats {note}"] = zynthian_controller(self, f"beats {note}", { + "name": "beats", + "processor": processor, + "is_integer": True, + "value": 0, + "value_min": 0, + "value_max": MAX_BEATS + }) + zctrls[f"mode {note}"] = zynthian_controller(self, f"mode {note}", { + "name": "mode", + "processor": processor, + "is_integer": True, + "labels": ["disabled", "loop"] + [f"play {i}" for i in range(1, 25)], + "value_min": 0, + "value_max": 25, + "value": 1 + }) + zctrls[f"gain {note}"] = zynthian_controller(self, f"gain {note}", { + "name": "gain (dB)", + "processor": processor, + "value_min": -12.0, + "value_max": 6.0, + "value": 0.0, + }) + zctrls[f"crop_start {note}"] = zynthian_controller(self, f"crop_start {note}", { + "name": "crop start", + "processor": processor, + "is_integer": True + }) + zctrls[f"crop_end {note}"] = zynthian_controller(self, f"crop_end {note}", { + "name": "crop end", + "processor": processor, + "is_integer": True + }) + zctrls[f"zoom {note}"] = zynthian_controller(self, f"zoom {note}", { + "name": "zoom", + "processor": processor, + "ticks": [0, 1, 2], + "labels": ["x1", "x2", "x4"] + }) + processor.controllers_dict.update(zctrls) + + def update_controllers(self, processor, note, frames=100, reset=False): + if reset: + processor.controllers_dict[f"crop_start {note}"].value_max = frames + processor.controllers_dict[f"crop_start {note}"].value = 0 + processor.controllers_dict[f"crop_end {note}"].value_max = frames + processor.controllers_dict[f"crop_end {note}"].value = frames + ticks = [] + labels = [] + i = 0 + while True: + val = 2 ** i + if val > frames / 40: + break + ticks.append(val) + labels.append(f"x{ticks[i]}") + i += 1 + processor.controllers_dict[f"zoom {note}"] = zynthian_controller(self, f"zoom {note}", { + "name": "zoom", + "processor": processor, + "ticks": ticks, + "labels": labels + }) + self.update_nudge(processor, note) + + def remove_tmp_file(self, processor, phrase): + note = phrase + 1 + try: + path = processor.controllers_dict[f"file {note}"].path + if path.startswith("/tmp"): + os.remove(path) + except: + pass + + # --------------------------------------------------------------------------- + # Processor Management + # --------------------------------------------------------------------------- + + def add_processor(self, processor): + """ + Add a processor (clip player channel) + + processor: zynthian_processor object + """ + + if processor.midi_chan is None: + midi_chan = self.libclippy.addPlayer(255) + else: + midi_chan = self.libclippy.addPlayer(processor.midi_chan) + if midi_chan > 15: + return + #processor.midi_chan = midi_chan + 16 + self.processors.append(processor) + self.state_manager.chain_manager.set_midi_chan(processor.chain_id, midi_chan + 16) + processor.jackname = f"{self.jackname}:out_{midi_chan + 1 :02d}" + + self.zynseq.enable_channel(processor.midi_chan, True) + for phrase in range(self.zynseq.phrases): + note = phrase + 1 + self.add_controllers(processor, note) + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, processor.midi_chan, "repeat", 0) + self.set_phrase(processor, self.zynseq.phrase) + + def remove_processor(self, processor): + self.zynseq.enable_channel(processor.midi_chan, False) + if self.libclippy.removePlayer(processor.midi_chan - 16) != 0: + return + for phrase in range(self.zynseq.phrases): + self.remove_tmp_file(processor, phrase) + super().remove_processor(processor) + + # --------------------------------------------------------------------------- + # MIDI Channel Management + # --------------------------------------------------------------------------- + + # --------------------------------------------------------------------------- + # Bank Management + # --------------------------------------------------------------------------- + + def get_bank_list(self, processor=None): + return [] + + #def set_bank(self, processor, bank): + # return True + + # --------------------------------------------------------------------------- + # Preset Management + # --------------------------------------------------------------------------- + + #def get_preset_list(self, bank, processor=None): + # return [] + + #def set_preset(self, processor, preset, preload=False): + # return False + + # --------------------------------------------------------------------------- + # Specific functions + # --------------------------------------------------------------------------- + + # --------------------------------------------------------------------------- + # API methods + # --------------------------------------------------------------------------- + + +# ****************************************************************************** diff --git a/zyngine/zynthian_engine_fluidsynth.py b/zyngine/zynthian_engine_fluidsynth.py index 4dbedd7f4..49890ba16 100644 --- a/zyngine/zynthian_engine_fluidsynth.py +++ b/zyngine/zynthian_engine_fluidsynth.py @@ -487,7 +487,7 @@ def zynapi_install(cls, dpath, bank_path): @classmethod def zynapi_get_formats(cls): - return "sf2,sf3,zip,tgz,tar.gz,tar.bz2,tar.xz" + return "sf2,sf3,zip,tgz,tar.gz,tar.bz2,tar.xz,7z,rar" @classmethod def zynapi_martifact_formats(cls): diff --git a/zyngine/zynthian_engine_inet_radio.py b/zyngine/zynthian_engine_inet_radio.py index 34765ef4f..fcf35be9d 100644 --- a/zyngine/zynthian_engine_inet_radio.py +++ b/zyngine/zynthian_engine_inet_radio.py @@ -22,7 +22,6 @@ # # ****************************************************************************** -from collections import OrderedDict import copy import json import socket @@ -529,8 +528,8 @@ class zynthian_engine_inet_radio(zynthian_engine): # Initialization # --------------------------------------------------------------------------- - def __init__(self, zyngui=None): - super().__init__(zyngui) + def __init__(self, state_manager=None): + super().__init__(state_manager) self.name = "InternetRadio" self.nickname = "IR" self.jackname = "inetradio" @@ -610,6 +609,7 @@ def start(self): self.client.connect(("localhost", 4212)) # TODO Assign port in config self.client.recv(4096) self.client.send("zynthian\n".encode()) + self.running= True self.start_proc_poll_thread() except Exception as err: @@ -620,6 +620,8 @@ def stop(self): if self.proc: try: logging.info("Stopping Engine " + self.name) + self.running = False + self.proc_poll_thread.join() self.proc_cmd("shutdown") self.proc.terminate() try: @@ -644,7 +646,7 @@ def proc_poll_thread_task(self): last_status = 0 last_info = 0 line = "" - while self.proc.poll() is None: + while self.running and self.proc.poll() is None: now = monotonic() if self.preset_i == self.pending_preset_i: if now > last_info + 5: diff --git a/zyngine/zynthian_engine_jalv.py b/zyngine/zynthian_engine_jalv.py index 967ef562e..cb4cffb2a 100644 --- a/zyngine/zynthian_engine_jalv.py +++ b/zyngine/zynthian_engine_jalv.py @@ -84,7 +84,9 @@ class zynthian_engine_jalv(zynthian_engine): 'http://gareus.org/oss/lv2/tuna#mod': zynthian_engine.ui_dir + "/zyngui/zynthian_widget_tunaone.py", 'http://looperlative.com/plugins/lp3-basic': zynthian_engine.ui_dir + "/zyngui/zynthian_widget_looper.py", 'http://aidadsp.cc/plugins/aidadsp-bundle/rt-neural-loader': zynthian_engine.ui_dir + "/zyngui/zynthian_widget_aidax.py", - 'http://github.com/mikeoliphant/neural-amp-modeler-lv2': zynthian_engine.ui_dir + "/zyngui/zynthian_widget_nam.py" + 'http://github.com/mikeoliphant/neural-amp-modeler-lv2': zynthian_engine.ui_dir + "/zyngui/zynthian_widget_nam.py", + 'http://guitarix.sourceforge.net/plugins/gx_graphiceq_#_graphiceq_': zynthian_engine.ui_dir + "/zyngui/zynthian_widget_GxGraphicEQ.py", + 'http://guitarix.sourceforge.net/plugins/gx_barkgraphiceq_#_barkgraphiceq_': zynthian_engine.ui_dir + "/zyngui/zynthian_widget_GxGraphicEQ.py", } # For certain plugins its beneficial to set parameters not set @@ -491,8 +493,8 @@ def start_proc_poll_thread(self): # --------------------------------------------------------------------------- def add_processor(self, processor): - super().add_processor(processor) self.set_midi_chan(processor) + super().add_processor(processor) def get_name(self, processor=None): return self.plugin_name @@ -910,7 +912,8 @@ def get_lv2_controllers_dict(self): 'path_file_types': None, 'not_on_gui': info['not_on_gui'], 'display_priority': display_priority, - 'envelope': info['envelope'] + 'envelope': info['envelope'], + 'filter': info['filter'] }) # If control info is not OK @@ -918,9 +921,27 @@ def get_lv2_controllers_dict(self): #logging.error(e) logging.exception(traceback.format_exc()) - # Sort by suggested display_priority => This is done in zynthian_engine! - #new_index = sorted(zctrls, key=lambda x: zctrls[x].display_priority, reverse=True) - #zctrls = {k: zctrls[k] for k in new_index} + if self.type == "Audio Effect": + if "bypass" in zctrls: + zctrls["bypass"].labels = ["inline", "bypass"] + zctrls["bypass"].is_toggle = True + zctrls["bypass"].display_priority = 0 + elif "BYPASS" in zctrls: + zctrls["BYPASS"].labels = ["inline", "bypass"] + zctrls["BYPASS"].is_toggle = True + zctrls["BYPASS"].display_priority = 0 + else: + # Add jack-routing bypass control + zctrls["bypass"] = zynthian_controller(self, 'bypass', { + 'name': "bypass", + 'is_toggle': True, + 'value_max': 1, + 'value_default': 0, + 'value': 0, + 'processor': self, + 'labels': ['inline', 'bypass'], + "display_priority": 0 + }) return zctrls diff --git a/zyngine/zynthian_engine_linuxsampler.py b/zyngine/zynthian_engine_linuxsampler.py index 0d1f43fcb..9b734be27 100644 --- a/zyngine/zynthian_engine_linuxsampler.py +++ b/zyngine/zynthian_engine_linuxsampler.py @@ -32,7 +32,6 @@ from os.path import isfile from Levenshtein import distance from subprocess import check_output -from collections import OrderedDict from zynconf import ServerPort from zyncoder.zyncore import lib_zyncore @@ -168,7 +167,7 @@ def lscp_send_multi(self, command): self.state_manager.end_busy("linux_sampler") return None lines = result.decode().split("\r\n") - result = OrderedDict() + result = {} for line in lines: # logging.debug("LSCP RECEIVE => %s" % line) if line[0:2] == "OK": diff --git a/zyngine/zynthian_engine_modui.py b/zyngine/zynthian_engine_modui.py index 66e2384ae..daffdb2de 100644 --- a/zyngine/zynthian_engine_modui.py +++ b/zyngine/zynthian_engine_modui.py @@ -32,7 +32,6 @@ from time import sleep from subprocess import check_output from threading import Thread -from collections import OrderedDict # Zynthian specific modules from zyngine.zynthian_engine import zynthian_engine @@ -83,9 +82,9 @@ def __init__(self, state_manager=None): def reset(self): super().reset() self.graph = {} - self.plugin_info = OrderedDict() - self.plugin_zctrls = OrderedDict() - self.pedal_presets = OrderedDict() + self.plugin_info = {} + self.plugin_zctrls = {} + self.pedal_presets = {} self.pedal_preset_noun = 'snapshot' def get_jackname(self): @@ -240,7 +239,7 @@ def get_preset_favs(self, processor): if self.preset_favs is None: self.load_preset_favs() - result = OrderedDict() + result = {} for k, v in self.preset_favs.items(): if v[1][0] in [p[0] for p in processor.preset_list]: result[k] = v diff --git a/zyngine/zynthian_engine_pianoteq.py b/zyngine/zynthian_engine_pianoteq.py index e0d4c38d3..377e51123 100644 --- a/zyngine/zynthian_engine_pianoteq.py +++ b/zyngine/zynthian_engine_pianoteq.py @@ -582,7 +582,7 @@ def start(self): try: sr = self.state_manager.get_jackd_samplerate() except: - sr = 44100 + sr = 48000 fix_pianoteq_config(sr) super().start() # TODO: Use lightweight Popen - last attempt stopped RPC working # Wait for RPC interface to be available or 10s for <7.5 with GUI diff --git a/zyngine/zynthian_engine_setbfree.py b/zyngine/zynthian_engine_setbfree.py index f7c0e6cb4..653060d2a 100644 --- a/zyngine/zynthian_engine_setbfree.py +++ b/zyngine/zynthian_engine_setbfree.py @@ -295,7 +295,6 @@ def start(self): self.processors[i].get_bank_list() self.processors[i].set_bank(0, False) chain.audio_out = [] - chain.mixer_chan = None except Exception as e: logging.error(f"{manual} manual processor can't be added! => {e}") else: @@ -305,7 +304,6 @@ def start(self): self.set_midi_chan(self.processors[i]) self.processors[i].refresh_controllers() chain.audio_out = [] - chain.mixer_chan = None if self.manuals_split_config: # Enable Active MIDI Channel for splitted configurations diff --git a/zyngine/zynthian_engine_sfizz.py b/zyngine/zynthian_engine_sfizz.py index 9ce8958eb..511091f18 100644 --- a/zyngine/zynthian_engine_sfizz.py +++ b/zyngine/zynthian_engine_sfizz.py @@ -4,7 +4,7 @@ # # zynthian_engine implementation for Sfizz # -# Copyright (C) 2015-2023 Fernando Moyano +# Copyright (C) 2015-2026 Fernando Moyano # # ****************************************************************************** # @@ -28,8 +28,12 @@ import shutil import logging from subprocess import check_output +import yaml +from pathlib import Path from zyngine.zynthian_engine_sfz import zynthian_engine_sfz +SFIZZ_YAML_URL = "https://raw.githubusercontent.com/sfztools/sfztools.github.io/refs/heads/master/data/sfizz/support.yml" +SFIZZ_SUPPORTED = "/zynthian/config/sfizz_supported.yml" # ------------------------------------------------------------------------------ # Sfizz Engine Class @@ -59,13 +63,22 @@ def __init__(self, state_manager=None, jackname=None): if jackname: self.jackname = jackname else: - self.jackname = self.state_manager.chain_manager.get_next_jackname( - "sfizz") + self.jackname = self.state_manager.chain_manager.get_next_jackname("sfizz") - self.preload_size = 32768 # 8192, 16384, 32768, 65536 - self.num_voices = 40 self.sfzpath = None + pi_version = int(os.environ.get("RBPI_VERSION_NUMBER", "4")) + if pi_version >= 5: + self.num_voices = 64 + self.preload_size = 8192 # 8192, 16384, 32768, 65536 + elif pi_version == 4: + self.num_voices = 48 + self.preload_size = 16384 # 8192, 16384, 32768, 65536 + else: + self.num_voices = 32 + self.preload_size = 32768 # 8192, 16384, 32768, 65536 + + self.command = f"sfizz_jack --client_name '{self.jackname}' --preload_size {self.preload_size} --num_voices {self.num_voices}" self.command_prompt = "> " @@ -273,10 +286,104 @@ def zynapi_install(cls, dpath, bank_path): @classmethod def zynapi_get_formats(cls): - return "zip,tgz,tar.gz,tar.bz2,tar.xz" + return "zip,tgz,tar.gz,tar.bz2,tar.xz,7z,rar" @classmethod def zynapi_martifact_formats(cls): return "sfz" + @classmethod + def get_supported_opcode_patterns(cls, download=False): + """ Get list of regex patterns describing sfizz supported sfz codes + Args: + download: True to download latest support yaml file + Returns: List of regex patterns describing supported opcodes + """ + + patterns = [] + if download and os.system(f"wget -N -O {SFIZZ_SUPPORTED} {SFIZZ_YAML_URL} > /dev/null 2>&1") != 0: + return patterns + + try: + with open(SFIZZ_SUPPORTED, "r") as f: + data = yaml.safe_load(f) + except: + return patterns + if not data: + return patterns + + opcodes = data.get("opcodes", []) + for cat in data['categories']: + opcodes += cat.get('opcodes', []) + for op in opcodes: + if "support" in op: + continue + name = op.get("name") + if not name: + continue + escaped = re.escape(name) + escaped = re.sub(r"[A-Z]", r"\\d+", escaped) + patterns.append(re.compile(f"^{escaped}$")) + return patterns + + @classmethod + def get_used_codes(cls, filename): + """Extract headers and opcodes from a .sfz file + Args: + filename: Full path of sfz file + Returns: Dict of headers and opcodes used by sfz + """ + + used = {"headers": set(), "opcodes": set()} + with open(filename, "r", encoding="utf-8") as f: + for line in f: + line = re.sub(r"//.*", "", line) + line = re.sub(r"/\*.*?\*/", "", line) + headers = re.findall(r"<[^>]+>", line) + used["headers"].update(headers) + matches = re.findall(r"([A-Za-z0-9_]+)\s*=", line) + used["opcodes"].update(matches) + return used + + @classmethod + def get_all_sfz(cls, path): + """ Get a list of all sfz files within a directory branch (recurssive) + Args: + path: Path of directory to search + Returns: List of sfz files + """ + + return list(Path(path).rglob("*.sfz")) + + @classmethod + def is_opcode_supported(cls, opcode, patterns): + """ Check if opcode matches any supported pattern + Args: + opcode: Opcode to validate + patterns: List of regexp patterns used to validate + Returns: True if valid opcode + """ + + for pattern in patterns: + if pattern.match(opcode): + return True + return False + + @classmethod + def get_unsupported_opcodes(cls, filename, patterns): + """ Get list of unsupported opcodes used by sfz + Args: + filename: Full path of sfz file + patterns: List of regex patterns describing supported opcodes (see get_supported_opcode_patterns) + Returns: List of unsupported opcodes + """ + + unsupported = [] + for opcode in zynthian_engine_sfizz.get_used_codes(filename)["opcodes"]: + if not zynthian_engine_sfizz.is_opcode_supported(opcode, patterns): + unsupported.append(opcode) + return unsupported + + + # ****************************************************************************** diff --git a/zyngine/zynthian_engine_sooperlooper.py b/zyngine/zynthian_engine_sooperlooper.py index 1941235b0..b2ec1cc11 100644 --- a/zyngine/zynthian_engine_sooperlooper.py +++ b/zyngine/zynthian_engine_sooperlooper.py @@ -4,7 +4,7 @@ # # zynthian_engine implementation for sooper looper # -# Copyright (C) 2022-2025 Brian Walton +# Copyright (C) 2022-2026 Brian Walton # # ****************************************************************************** # @@ -65,868 +65,869 @@ # Sooper Looper Engine Class # ------------------------------------------------------------------------------ + class zynthian_engine_sooperlooper(zynthian_engine): - # --------------------------------------------------------------------------- - # Config variables - # --------------------------------------------------------------------------- - SL_PORT = ServerPort["sooperlooper_osc"] - MAX_LOOPS = 6 - - # SL_LOOP_SEL_PARAM act on the selected loop - send with osc command /sl/#/set where #=-3 for selected or index of loop (0..5) - SL_LOOP_SEL_PARAM = [ - 'record', - 'overdub', - 'multiply', - 'replace', - 'substitute', - 'insert', - 'trigger', - 'mute', - 'oneshot', - 'pause', - 'reverse', - 'single_pedal' - ] - - # SL_LOOP_PARAMS act on individual loops - sent with osc command /sl/#/set - SL_LOOP_PARAMS = [ - 'feedback', # range 0 -> 1 - #'dry', # range 0 -> 1 - 'wet', # range 0 -> 1 - #'input_gain', # range 0 -> 1 - 'rate', # range 0.25 -> 4.0 - #'scratch_pos', # range 0 -> 1 - #'delay_trigger', # any changes - #'quantize', # 0 = off, 1 = cycle, 2 = 8th, 3 = loop - #'round', # 0 = off, not 0 = on - #'redo_is_tap' # 0 = off, not 0 = on - 'sync', # 0 = off, not 0 = on - 'playback_sync', # 0 = off, not 0 = on - #'use_rate', # 0 = off, not 0 = on - #'fade_samples', # 0 -> ... - 'use_feedback_play', # 0 = off, not 0 = on - #'use_common_ins', # 0 = off, not 0 = on - #'use_common_outs', # 0 = off, not 0 = on - #'relative_sync', # 0 = off, not 0 = on - #'use_safety_feedback', # 0 = off, not 0 = on - #'pan_1', # range 0 -> 1 - #'pan_2', # range 0 -> 1 - #'pan_3', # range 0 -> 1 - #'pan_4', # range 0 -> 1 - #'input_latency', # range 0 -> ... - #'output_latency', # range 0 -> ... - #'trigger_latency', # range 0 -> ... - #'autoset_latency', # 0 = off, not 0 = on - #'mute_quantized', # 0 = off, not 0 = on - #'overdub_quantized', # 0 == off, not 0 = on - #'replace_quantized', # 0 == off, not 0 = on (undocumented) - #'discrete_prefader', # 0 == off, not 0 = on - #'next_state,' # same as state - 'stretch_ratio', # 0.5 -> 4.0 (undocumented) - #'tempo_stretch', # 0 = off, not 0 = on (undocumented) - 'pitch_shift' # -12 -> 12 (undocumented) - ] - - # SL_LOOP_GLOBAL_PARAMS act on all loops - sent with osc command /sl/-1/set - SL_LOOP_GLOBAL_PARAMS = [ - 'rec_thresh', # range 0 -> 1 - 'round', # 0 = off, not 0 = on - 'relative_sync', # 0 = off, not 0 = on - 'quantize', # 0 = off, 1 = cycle, 2 = 8th, 3 = loop - 'mute_quantized', # 0 = off, not 0 = on - 'overdub_quantized', # 0 == off, not 0 = on - 'replace_quantized', # 0 == off, not 0 = on (undocumented) - ] - - # SL_GLOBAL_PARAMS act on whole engine - sent with osc command /set - SL_GLOBAL_PARAMS = [ - #'tempo', # bpm - #'eighth_per_cycle', - 'dry', # range 0 -> 1 affects common input passthru - #'wet', # range 0 -> 1 affects common output level - 'input_gain', # range 0 -> 1 affects common input gain - 'sync_source', # -3 = internal, -2 = midi, -1 = jack, 0 = none, # > 0 = loop number (1 indexed) - #'tap_tempo', # any changes - #'save_loop', # any change triggers quick save, be careful - #'auto_disable_latency', # when 1, disables compensation when monitoring main inputs - #'select_next_loop', # any changes - #'select_prev_loop', # any changes - #'select_all_loops', # any changes - 'selected_loop_num', # -1 = all, 0->N selects loop instances (first loop is 0, etc) - #'smart_eighths', # 0 = off, not 0 = on (undocumented) - ] - - SL_MONITORS = [ - 'rate_output', # Used to detect direction but must use register_auto_update - 'in_peak_meter', # absolute float sample value 0.0 -> 1.0 (or higher) - #'out_peak_meter', # absolute float sample value 0.0 -> 1.0 (or higher) - #'loop_len', # in seconds - #'loop_pos', # in seconds - #'cycle_len', # in seconds - #'free_time', # in seconds - #'total_time', # in seconds - #'is_soloed', # 1 if soloed, 0 if not - ] - - SL_STATES = { - # Dictionary of SL states with indication of which controllers are on/off in this SL state - SL_STATE_UNKNOWN: { - 'name': 'unknown', - 'ctrl_off': [], - 'ctrl_on': [], - 'next_state': False, - 'icon': '' - }, - SL_STATE_OFF: { - 'name': 'off', - 'ctrl_off': ['mute'], - 'ctrl_on': [], - 'next_state': False, - 'icon': '' - }, - SL_STATE_REC_STARTING: { - 'name': 'rec starting...', - 'ctrl_off': ['overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], - 'ctrl_on': ['record'], - 'next_state': False, - 'icon': '\u23EF' - }, - SL_STATE_RECORDING: { - 'name': 'recording', - 'ctrl_off': ['overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], - 'ctrl_on': ['record'], - 'next_state': False, - 'icon': '\u26AB' - }, - SL_STATE_REC_STOPPING: { - 'name': 'rec stopping...', - 'ctrl_off': ['overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], - 'ctrl_on': ['record'], - 'next_state': False, - 'icon': '\u23EF' - }, - SL_STATE_PLAYING: { - 'name': 'playing', - 'ctrl_off': ['record','overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], - 'ctrl_on': [], - 'next_state': False, - 'icon': '\uF04B' - }, - SL_STATE_OVERDUBBING: { - 'name': 'overdubbing', - 'ctrl_off': ['record', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], - 'ctrl_on': ['overdub'], - 'next_state': False, - 'icon': '\u26AB' - }, - SL_STATE_MULTIPLYING: { - 'name': 'multiplying', - 'ctrl_off': ['record', 'overdub', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], - 'ctrl_on': ['multiply'], - 'next_state': True, - 'icon': '\u26AB' - }, - SL_STATE_INSERTING: { - 'name': 'inserting', - 'ctrl_off': ['record', 'overdub', 'multiply', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], - 'ctrl_on': ['insert'], - 'next_state': True, - 'icon': '\u26AB' - }, - SL_STATE_REPLACING: { - 'name': 'replacing', - 'ctrl_off': ['record', 'overdub', 'insert', 'multiply', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], - 'ctrl_on': ['replace'], - 'next_state': True, - 'icon': '\u26AB' - }, - SL_STATE_DELAYING: { - 'name': 'delaying', - 'ctrl_off': [], - 'ctrl_on': [], - 'next_state': False, - 'icon': 'delay' - }, - SL_STATE_MUTED: { - 'name': 'muted', - 'ctrl_off': ['record', 'overdub', 'insert', 'multiply', 'replace', 'substitute', 'pause', 'trigger', 'oneshot'], - 'ctrl_on': ['mute'], - 'next_state': False, - 'icon': 'mute' - }, - SL_STATE_SCRATCHING: { - 'name': 'scratching', - 'ctrl_off': ['record', 'overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], - 'ctrl_on': [], - 'next_state': False, - 'icon': 'scratch' - }, - SL_STATE_PLAYING_ONCE: { - 'name': 'playing once', - 'ctrl_off': ['record', 'overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger'], - 'ctrl_on': ['oneshot'], - 'next_state': True, - 'icon': '\uF04B' - }, - SL_STATE_SUBSTITUTING: { - 'name': 'substituting', - 'ctrl_off': ['record', 'overdub', 'multiply', 'insert', 'replace', 'pause', 'mute', 'trigger','oneshot'], - 'ctrl_on': ['substitute'], - 'next_state': True, - 'icon': '\u26AB' - }, - SL_STATE_PAUSED: { - 'name': 'paused', - 'ctrl_off': ['record', 'overdub', 'insert', 'multiply', 'replace', 'substitute', 'mute', 'trigger', 'oneshot'], - 'ctrl_on': ['pause'], - 'next_state': False, - 'icon': '\u23F8'}, - SL_STATE_UNDO_ALL: { - 'name': 'undo all', - 'ctrl_off': [], - 'ctrl_on': [], - 'next_state': False, - 'icon': '' - }, - SL_STATE_TRIGGER_PLAY: { - 'name': 'trigger play...', - 'ctrl_off': ['record', 'overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'oneshot'], - 'ctrl_on': ['trigger'], - 'next_state': False, - 'icon': '' - }, - SL_STATE_UNDO: { - 'name': 'undo', - 'ctrl_off': [], - 'ctrl_on': [], - 'next_state': False, - 'icon': '' - }, - SL_STATE_REDO: { - 'name': 'redo', - 'ctrl_off': [], - 'ctrl_on': [], - 'next_state': False, - 'icon': '' - }, - SL_STATE_REDO_ALL: { - 'name': 'redo all', - 'ctrl_off': [], - 'ctrl_on': [], - 'next_state': False, - 'icon': '' - }, - SL_STATE_OFF_MUTED: { - 'name': 'off muted', - 'ctrl_off': ['record', 'overdub', 'insert', 'multiply', 'replace', 'substitute', 'pause', 'trigger', 'oneshot'], - 'ctrl_on': ['mute'], - 'next_state': False, - 'icon': 'mute' - } - } - - # --------------------------------------------------------------------------- - # Initialization - # --------------------------------------------------------------------------- - - def __init__(self, state_manager=None): - super().__init__(state_manager) - self.name = "SooperLooper" - self.nickname = "SL" - self.jackname = "sooperlooper" - self.type = "Audio Effect" - - self.osc_target_port = self.SL_PORT - - # Load custom MIDI bindings - custom_slb_fpath = self.config_dir + "/sooperlooper/zynthian.slb" - if os.path.exists(custom_slb_fpath): - logging.info(f"loading sooperlooper custom MIDI bindings: {custom_slb_fpath}") - else: - custom_slb_fpath = None - - # Build SL command line - #if self.config_remote_display(): - # self.command = ["slgui", "-l 0", f"-P {self.osc_target_port}", f"-J {self.jackname}"] - #else: - #self.command = ["sooperlooper", "-q", "-l 0", "-D no", f"-p {self.osc_target_port}", f"-j {self.jackname}"] - self.command = ["sooperlooper", "-q", "-D no", f"-p {self.osc_target_port}", f"-j {self.jackname}"] - - if custom_slb_fpath: - self.command += ["-m", custom_slb_fpath] - - self.state = [-1] * self.MAX_LOOPS # Current SL state for each loop - self.next_state = [-1] * self.MAX_LOOPS # Next SL state for each loop (-1 if no state change pending) - self.waiting = [0] * self.MAX_LOOPS # 1 if a change of state is pending - self.selected_loop = None - self.loop_count = 1 - self.channels = 2 - self.selected_loop_cc_binding = True # True for MIDI CC to control selected loop. False to target all loops - - ui_dir = os.environ.get('ZYNTHIAN_UI_DIR', "/zynthian/zynthian-ui") - self.custom_gui_fpath = f"{ui_dir}/zyngui/zynthian_widget_sooperlooper.py" - self.monitors_dict = { - "state": 0, - "next_state": -1, - "loop_count": 0 - } - self.pedal_time = 0 # Time single pedal was asserted - self.pedal_taps = 0 # Quantity of taps on single pedal - self.single_pedal_timer = None # Timer used for long pedal press - - # MIDI Controllers - loop_labels = [] - for i in range(self.MAX_LOOPS): - loop_labels.append(str(i + 1)) - self._ctrls = [ - #symbol, {options}, midi_cc - ['record', {'name': 'record', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], - ['overdub', {'name': 'overdub', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], - ['multiply', {'name': 'multiply', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], - ['replace', {'name': 'replace', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], - ['substitute', {'name': 'substitute', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], - ['insert', {'name': 'insert', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], - ['undo/redo', {'value': 1, 'labels': ['<', '<>', '>']}], - ['next_loop', {'name': 'next loop', 'value': 0, 'value_max': 127, 'labels': ['>', '']}], - ['trigger', {'name': 'trigger', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], - ['mute', {'name': 'mute', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], - ['oneshot', {'name': 'oneshot', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], - ['pause', {'name': 'pause', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], - ['reverse', {'name': 'direction', 'value': 0, 'labels': ['reverse', 'forward'], 'ticks':[1, 0], 'is_toggle': True}], - ['rate', {'name': 'speed', 'value': 1.0, 'value_min': 0.25, 'value_max': 4.0, 'is_integer': False, 'nudge_factor': 0.01}], - ['stretch_ratio', {'name': 'stretch', 'value': 1.0, 'value_min': 0.5, 'value_max': 4.0, 'is_integer': False, 'nudge_factor': 0.01}], - ['pitch_shift', {'name': 'pitch', 'value': 0.0, 'value_min': -12, 'value_max': 12, 'is_integer': False, 'nudge_factor': 0.05}], - ['sync_source', {'name': 'sync to', 'value': 1, 'value_min': -3, 'value_max': 1, 'labels': ['Internal', 'MidiClock', 'Jack/Host', 'None', 'Loop1'], 'is_integer': True}], - ['sync', {'name': 'enable sync', 'value': 1, 'value_max': 1, 'labels': ['off', 'on']}], - ['eighth_per_cycle', {'name': '8th/cycle', 'value': 16, 'value_min': 1, 'value_max': 600}], # TODO: What makes sense for max val? - ['quantize', {'value': 1, 'value_max': 3, 'labels': ['off', 'cycle', '8th', 'loop']}], - ['mute_quantized', {'name': 'mute quant', 'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], - ['overdub_quantized', {'name': 'overdub quant', 'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], - ['replace_quantized', {'name': 'replace quant', 'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], - ['round', {'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], - ['relative_sync', {'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], - ['smart_eighths', {'name': 'auto 8th', 'value': 1, 'value_max': 1, 'labels': ['off', 'on']}], - ['playback_sync', {'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], - ['use_feedback_play', {'name': 'play feedback', 'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], - ['rec_thresh', {'name': 'threshold', 'value': 0.0, 'value_max': 1.0, 'is_integer': False, 'is_logarithmic': True}], - ['feedback', {'value': 1.0, 'value_max': 1.0, 'is_integer': False, 'is_logarithmic': True}], - ['dry', {'value': 1.0, 'value_max': 1.0, 'is_integer': False, 'is_logarithmic': True}], - ['wet', {'value': 1.0, 'value_max': 1.0, 'is_integer': False, 'is_logarithmic': True}], - ['input_gain', {'name': 'input gain', 'value': 1.0, 'value_max': 1.0, 'is_integer': False, 'is_logarithmic': True}], - ['loop_count', {'name': 'loop count', 'value': 1, 'value_min': 1, 'value_max': self.MAX_LOOPS}], - ['selected_loop_num', {'name': 'selected loop', 'value': 1, 'value_min': 1, 'value_max': 6}], - ['single_pedal', {'name': 'single pedal', 'value': 0, 'value_max': 1, 'labels': ['>', '<'], 'is_toggle': True}], - ['selected_loop_cc', {'name': 'midi cc to selected loop', 'value': 127, 'labels':['off', 'on']}], - ['load_file', {'name': 'load file', 'is_path': True, 'path_file_types': ["wav"], 'path_dir_names': []}] - ] - - # Controller Screens - self._ctrl_screens = [ - ['Loop record 1', ['record', 'overdub', 'multiply', 'undo/redo']], - ['Loop record 2', ['replace', 'substitute', 'insert', 'undo/redo']], - ['Loop control', ['trigger', 'oneshot', 'mute', 'pause']], - ['Loop time/pitch', ['reverse', 'rate', 'stretch_ratio', 'pitch_shift']], - ['Loop levels', ['wet', 'dry', 'feedback', 'selected_loop_num']], - ['Global loop', ['selected_loop_num', 'loop_count', 'next_loop', 'single_pedal']], - ['Global levels', ['rec_thresh', 'input_gain', 'load_file']], - ['Global quantize', ['quantize', 'mute_quantized', 'overdub_quantized', 'replace_quantized']], - ['Global sync 1', ['sync_source', 'sync', 'playback_sync', 'relative_sync']], - ['Global sync 2', ['round', 'use_feedback_play', 'selected_loop_cc']] - ] - - self.start() - - # --------------------------------------------------------------------------- - # Subproccess Management & IPC - # --------------------------------------------------------------------------- - - def start(self): - logging.debug(f"Starting SooperLooper with command: {self.command}") - self.osc_init() - self.proc = Popen(self.command, stdout=DEVNULL, stderr=DEVNULL, env=self.command_env, cwd=self.command_cwd) - sleep(1) # TODO: Cludgy wait - maybe should perform periodic check for server until reachable - - # Register for common events from sooperlooper server - request changes to the currently selected loop - for symbol in self.SL_MONITORS: - self.osc_server.send(self.osc_target, '/sl/-3/register_auto_update', ('s', symbol), ('i', 100), ('s', self.osc_server_url), ('s', '/monitor')) - for symbol in self.SL_LOOP_PARAMS: - self.osc_server.send(self.osc_target, '/sl/-3/register_auto_update', ('s', symbol), ('i', 100), ('s', self.osc_server_url), ('s', '/control')) - for symbol in self.SL_LOOP_GLOBAL_PARAMS: - # Register for tallies of commands sent to all channels - self.osc_server.send(self.osc_target, '/sl/-3/register_auto_update', ('s', symbol), ('i', 100), ('s', self.osc_server_url), ('s', '/control')) - - # Register for global events from sooperlooper - for symbol in self.SL_GLOBAL_PARAMS: - self.osc_server.send(self.osc_target, '/register_auto_update', ('s', symbol), ('i', 100), ('s', self.osc_server_url), ('s', '/control')) - self.osc_server.send(self.osc_target, '/register', ('s', self.osc_server_url), ('s', '/info')) - - # Request current quantity of loops - self.osc_server.send(self.osc_target, '/ping', ('s', self.osc_server_url), ('s', '/info')) - - def stop(self): - if self.proc: - try: - logging.info("Stoping Engine " + self.name) - self.proc.terminate() - try: - self.proc.wait(0.2) - except: - self.proc.kill() - self.proc = None - except Exception as err: - logging.error(f"Can't stop engine {self.name} => {err}") - self.osc_end() - - # --------------------------------------------------------------------------- - # Processor Management - # --------------------------------------------------------------------------- - - # --------------------------------------------------------------------------- - # MIDI Channel Management - # --------------------------------------------------------------------------- - - # --------------------------------------------------------------------------- - # Bank Management - # --------------------------------------------------------------------------- - - # No bank support for sooperlooper - def get_bank_list(self, processor=None): - return [("", None, "", None)] - - # --------------------------------------------------------------------------- - # Preset Management - # --------------------------------------------------------------------------- - - def get_preset_list(self, bank, processor=None): - presets = self.get_filelist(f"{self.data_dir}/presets/sooperlooper", "slsess") - presets += self.get_filelist(f"{self.my_data_dir}/presets/sooperlooper", "slsess") - return presets - - def set_preset(self, processor, preset, preload=False): - if preload or self.osc_server is None: - return False - self.osc_server.send(self.osc_target, '/load_session', ('s', preset[0]), ('s', self.osc_server_url), ('s', '/error')) - sleep(0.5) # Wait for session to load to avoid consequent controller change conflicts - - # Request quantity of loops in session - self.osc_server.send(self.osc_target, '/ping', ('s', self.osc_server_url), ('s', '/info')) - - for symbol in self.SL_MONITORS: - self.osc_server.send(self.osc_target, '/sl/-3/get', ('s', symbol), ('s', self.osc_server_url), ('s', '/monitor')) - for symbol in self.SL_LOOP_PARAMS: - self.osc_server.send(self.osc_target, '/sl/-3/get', ('s', symbol), ('s', self.osc_server_url), ('s', '/control')) - for symbol in self.SL_LOOP_GLOBAL_PARAMS: - self.osc_server.send(self.osc_target, '/sl/-3/get', ('s', symbol), ('s', self.osc_server_url), ('s', '/control')) - for symbol in self.SL_GLOBAL_PARAMS: - self.osc_server.send(self.osc_target, '/get', ('s', symbol), ('s', self.osc_server_url), ('s', '/control')) - - sleep(0.5) # Wait for controls to update - - # Start loops (muted) to synchronise - self.osc_server.send(self.osc_target, '/sl/-1/hit', ('s', 'mute')) - return True - - def preset_exists(self, bank_info, preset_name): - return os.path.exists(f"{self.my_data_dir}/presets/sooperlooper/{preset_name}.slsess") - - def save_preset(self, bank_name, preset_name): - if self.osc_server is None: - return - path = f"{self.my_data_dir}/presets/sooperlooper" - if not os.path.exists(path): - try: - os.mkdir(path) - except Exception as e: - logging.warning(f"Failed to create SooperLooper user preset directory: {e}") - uri = f"{path}/{preset_name}.slsess" - # Undocumented feature: set 4th (int) parameter to 1 to save loop audio - self.osc_server.send(self.osc_target, '/save_session', ('s', uri), ('s', self.osc_server_url), ('s', '/error'), ('i', 1)) - return uri - - def delete_preset(self, bank, preset): - try: - os.remove(preset[0]) - wavs = glob(f"{preset[0]}_loop*.wav") - for file in wavs: - os.remove(file) - except Exception as e: - logging.debug(e) - - def rename_preset(self, bank_info, preset, new_name): - try: - os.rename(preset[0], f"{self.my_data_dir}/presets/sooperlooper/{new_name}.slsess") - return True - except Exception as e: - logging.debug(e) - return False - - # ---------------------------------------------------------------------------- - # Controllers Management - # ---------------------------------------------------------------------------- - - def get_controllers_dict(self, processor): - if not processor.controllers_dict: - for ctrl in self._ctrls: - ctrl[1]['processor'] = processor - if ctrl[0] in self.SL_LOOP_SEL_PARAM: - # Create a zctrl for each loop - for i in range(self.MAX_LOOPS): - name = ctrl[1].get('name') - specs = ctrl[1].copy() - specs['name'] = f"{name} ({i + 1})" - zctrl = zynthian_controller(self, f"{ctrl[0]}:{i}", specs) - processor.controllers_dict[zctrl.symbol] = zctrl - zctrl = zynthian_controller(self, ctrl[0], ctrl[1]) - processor.controllers_dict[zctrl.symbol] = zctrl - return processor.controllers_dict - - def adjust_controller_bindings(self): - if self.selected_loop_cc_binding: - self._ctrl_screens[0][1] = [f'record', f'overdub', f'multiply', 'undo/redo'] - self._ctrl_screens[1][1] = [f'replace', f'substitute', f'insert', 'undo/redo'] - self._ctrl_screens[2][1] = [f'trigger', f'oneshot', f'mute', f'pause'] - self._ctrl_screens[3][1][0] = f'reverse' - self._ctrl_screens[5][1][3] = f'single_pedal' - else: - self._ctrl_screens[0][1] = [f'record:{self.selected_loop}', f'overdub:{self.selected_loop}', f'multiply:{self.selected_loop}', 'undo/redo'] - self._ctrl_screens[1][1] = [f'replace:{self.selected_loop}', f'substitute:{self.selected_loop}', f'insert:{self.selected_loop}', 'undo/redo'] - self._ctrl_screens[2][1] = [f'trigger:{self.selected_loop}', f'oneshot:{self.selected_loop}', f'mute:{self.selected_loop}', f'pause:{self.selected_loop}'] - self._ctrl_screens[3][1][0] = f'reverse:{self.selected_loop}' - self._ctrl_screens[5][1][3] = f'single_pedal:{self.selected_loop}' - return - - def send_controller_value(self, zctrl): - try: - processor = self.processors[0] - except IndexError: - return - if zctrl.symbol == "selected_loop_cc": - self.selected_loop_cc_binding = zctrl.value != 0 - self.adjust_controller_bindings() - for symbol in self.SL_LOOP_SEL_PARAM: - self.osc_server.send(self.osc_target, '/sl/-1/get', ('s', symbol), ('s', self.osc_server_url), ('s', '/control')) - processor.refresh_controllers() - self.state_manager.send_cuia("refresh_screen", ["control"]) - return - elif zctrl.symbol == "load_file": - self.osc_server.send(self.osc_target, f"/sl/{self.selected_loop}/load_loop", ("s", zctrl.value), ('s', self.osc_server_url), ('s', '/error')) - zctrl.value = os.path.dirname(zctrl.value) - return - - if ":" in zctrl.symbol: - if processor.controllers_dict["selected_loop_cc"].value: - return - symbol, loop = zctrl.symbol.split(":") - loop = int(loop) - else: - symbol = zctrl.symbol - loop = self.selected_loop - if not processor.controllers_dict["selected_loop_cc"].value and zctrl.symbol in self.SL_LOOP_SEL_PARAM: - return - - if self.osc_server is None or symbol in ['oneshot', 'trigger'] and zctrl.value == 0: - # Ignore off signals - return - elif symbol in ("mute", "pause"): - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', symbol)) - elif symbol == 'single_pedal': - """ Single pedal logic - Idle -> Record - Record->Play - Play->Overdub - Overdub->Play - Double press: pause - Triple press or double press and hold: Clear - Press once and hold to record/overdub until release - """ - ts = monotonic() - pedal_dur = ts - self.pedal_time - self.pedal_time = ts - if zctrl.value: - # Pedal push - if pedal_dur < 0.5: - self.pedal_taps += 1 - else: - self.pedal_taps = 1 - - try: - self.single_pedal_timer.cancel() - self.single_pedal_timer = None - except: - pass - - match self.pedal_taps: - case 1: - # Single tap - if self.state[loop] in (SL_STATE_PLAYING, SL_STATE_OVERDUBBING, SL_STATE_MUTED): - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'overdub')) - if self.state[loop] in (SL_STATE_UNKNOWN, SL_STATE_OFF, SL_STATE_OFF_MUTED): - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'record')) - elif self.state[loop] == SL_STATE_RECORDING: - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'record')) - elif self.state[loop] == SL_STATE_PAUSED: - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'trigger')) - case 2: - # Double tap - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'pause')) - case 3: - # Triple tap - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'undo_all')) - self.single_pedal_timer = Timer(1.5, self.single_pedal_cb) - self.single_pedal_timer.start() - else: - # Pedal release: so check loop state, pedal press duration, etc. - try: - self.single_pedal_timer.cancel() - self.single_pedal_timer = None - except: - pass - if pedal_dur > 1.5: - # Handle press and hold record - if self.state[loop] == SL_STATE_OVERDUBBING: - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'overdub')) - elif self.state[loop] == SL_STATE_RECORDING: - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'record')) - - elif symbol == 'selected_loop_num': - self.select_loop(zctrl.value - 1, True) - elif symbol in self.SL_LOOP_PARAMS: # Selected loop - self.osc_server.send(self.osc_target, f'/sl/{loop}/set', ('s', symbol), ('f', zctrl.value)) - elif symbol in self.SL_LOOP_GLOBAL_PARAMS: # All loops - self.osc_server.send(self.osc_target, '/sl/-1/set', ('s', symbol), ('f', zctrl.value)) - elif symbol in self.SL_GLOBAL_PARAMS: # Global params - self.osc_server.send(self.osc_target, '/set', ('s', symbol), ('f', zctrl.value)) - - elif symbol == 'next_loop': - # Use single controller to perform next (CW) - if zctrl.value: - self.select_loop(self.selected_loop + 1, True, True) - zctrl.set_value(0, False) - elif zctrl.is_toggle: - # Use is_toggle to indicate the SL function is a toggle, i.e. press to engage, press to release - if symbol == 'record' and zctrl.value == 0 and self.state[loop] == SL_STATE_REC_STARTING: - # TODO: Implement better toggle of pending state - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'undo')) - return - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', symbol)) - #if symbol == 'trigger': - #zctrl.set_value(0, False) # Make trigger a pulse - elif symbol == 'undo/redo': - # Use single controller to perform undo (CCW) and redo (CW) - if zctrl.value == 0: - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'undo')) - elif zctrl.value == 2: - self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'redo')) - zctrl.set_value(1, False) - elif symbol == 'loop_count': - for loop in range(self.loop_count, zctrl.value): - self.osc_server.send(self.osc_target, '/loop_add', ('i', self.channels), ('f', 30), ('i', 0)) - if zctrl.value < self.loop_count: - # Don't remove loops - let GUI offer option to (confirm and) remove - zctrl.set_value(self.loop_count, False) - self.monitors_dict['loop_del'] = True - - def single_pedal_cb(self): - match self.pedal_taps: - case 2: - # Double tap + hold - clear loop - self.osc_server.send(self.osc_target, f'/sl/-3/hit', ('s', 'undo_all')) - self.pedal_taps = 0 - self.single_pedal_timer = None - - def get_monitors_dict(self): - return self.monitors_dict - - # ---------------------------------------------------------------------------- - # OSC Managament - # ---------------------------------------------------------------------------- - - def cb_osc_all(self, path, args, types, src): - if self.osc_server is None: - return - try: - processor = self.processors[0] - if path == '/state': - # args: i:Loop index, s:control, f:value - logging.debug("Loop State: %d %s=%0.1f", args[0], args[1], args[2]) - if args[0] < 0 or args[0] >= self.MAX_LOOPS: - return - state = int(args[2]) - loop = args[0] - if args[1] == 'next_state': - self.next_state[loop] = state - elif args[1] == 'state': - self.state[loop] = state - if state in [0, 4]: - self.next_state[loop] = -1 - elif args[1] == 'waiting': - self.waiting[loop] = state - - if self.next_state[loop] == self.state[loop]: - self.next_state[loop] = -1 - - self.monitors_dict[f"state_{loop}"] = self.state[loop] - self.monitors_dict[f"next_state_{loop}"] = self.next_state[loop] - self.monitors_dict[f"waiting_{loop}"] = self.waiting[loop] - if self.selected_loop == loop: - self.monitors_dict['state'] = self.state[loop] - self.monitors_dict['next_state'] = self.next_state[loop] - self.monitors_dict['waiting'] = self.waiting[loop] - self.update_state(loop) - - elif path == '/info': - # args: s:hosturl s:version i:loopcount - #logging.debug("Info: from %s ver: %s loops: %d", args[0], args[1], args[2]) - self.sl_version = args[1] - loop_count_changed = int(args[2]) - self.loop_count # +/- quantity of added/removed loops - self.loop_count = int(args[2]) - if loop_count_changed: - labels = ['Internal', 'MidiClock', 'Jack/Host', 'None'] - for loop in range(self.loop_count): - labels.append(f"Loop {loop + 1}") - try: - processor.controllers_dict['sync_source'].set_options({'labels': labels, 'ticks':[], 'value_max': self.loop_count}) - processor.controllers_dict['loop_count'].set_value(self.loop_count, False) - processor.controllers_dict['selected_loop_num'].value_max = self.loop_count - except: - pass # zctrls may not yet be initialised - if loop_count_changed > 0: - for i in range(loop_count_changed): - self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'loop_pos'), ('i', 100), ('s', self.osc_server_url), ('s', '/monitor')) - self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'loop_len'), ('i', 100), ('s', self.osc_server_url), ('s', '/monitor')) - self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'mute'), ('i', 100), ('s', self.osc_server_url), ('s', '/monitor')) - self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'state'), ('i', 100), ('s', self.osc_server_url), ('s', '/state')) - self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'next_state'), ('i', 100), ('s', self.osc_server_url), ('s', '/state')) - self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'waiting'), ('i', 100), ('s', self.osc_server_url), ('s', '/state')) - if self.loop_count > 1: - # Set defaults for new loops - self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/set", ('s', 'sync'), ('f', 1)) - self.select_loop(self.loop_count - 1, True) - - self.osc_server.send(self.osc_target, '/get', ('s', 'sync_source'), ('s', self.osc_server_url), ('s', '/control')) - if self.selected_loop is not None and self.selected_loop > self.loop_count: - self.select_loop(self.loop_count - 1, True) - - self.monitors_dict['loop_count'] = self.loop_count - self.monitors_dict['version'] = self.sl_version - elif path == '/control': - # args: i:Loop index, s:control, f:value - #logging.debug("Control: Loop %d %s=%0.2f", args[0], args[1], args[2]) - self.monitors_dict[args[1]] = args[2] - if args[1] == 'selected_loop_num': - self.select_loop(args[2]) - return - try: - processor.controllers_dict[args[1]].set_value(args[2], False) - processor.controllers_dict[f"{args[1]}:{self.selected_loop}"].set_value(args[2], False) - except Exception as e: - pass - #logging.warning("Unsupported tally (or zctrl not yet configured) %s (%f)", args[1], args[2]) - elif path == '/monitor': - # args: i:Loop index, s:control, f:value - # Handle events registered for selected loop - #logging.debug("Monitor: Loop %d %s=%0.2f", args[0], args[1], args[2]) - if args[0] == -3: - if args[1] == 'rate_output': - try: - if args[2] < 0.0: - processor.controllers_dict['reverse'].set_value(1, False) - else: - processor.controllers_dict['reverse'].set_value(0, False) - except: - pass # zctrls may not yet be initialised - self.monitors_dict[args[1]] = args[2] - else: - self.monitors_dict[f"{args[1]}_{args[0]}"] = args[2] - elif path == 'error': - logging.error(f"SooperLooper daemon error: {args[0]}") - except Exception as e: - logging.warning(e) - - # --------------------------------------------------------------------------- - # Specific functions - # --------------------------------------------------------------------------- - - # Update 'state' controllers of loop - def update_state(self, loop): - try: - processor = self.processors[0] - except: - return - try: - current_state = self.state[loop] - #logging.warning(f"loop: {loop} state: {current_state}") - # Turn off all controllers that are off in this state - for symbol in self.SL_STATES[current_state]['ctrl_off']: - if symbol in self.SL_LOOP_SEL_PARAM: - if self.selected_loop == loop: - processor.controllers_dict[symbol].set_readonly(False) - processor.controllers_dict[symbol].set_value(0, False) - symbol += f":{loop}" - processor.controllers_dict[symbol].set_readonly(False) - processor.controllers_dict[symbol].set_value(0, False) - # Turn on all controllers that are on in this state - for symbol in self.SL_STATES[current_state]['ctrl_on']: - if symbol in self.SL_LOOP_SEL_PARAM: - if self.selected_loop == loop: - processor.controllers_dict[symbol].set_readonly(False) - processor.controllers_dict[symbol].set_value(1, False) - symbol += f":{loop}" - processor.controllers_dict[symbol].set_readonly(False) - processor.controllers_dict[symbol].set_value(1, False) - next_state = self.next_state[loop] - # Set next_state for controllers that are part of logical sequence - if self.SL_STATES[next_state]['next_state']: - for symbol in self.SL_STATES[next_state]['ctrl_on']: - if symbol in self.SL_LOOP_SEL_PARAM: - if self.selected_loop == loop: - processor.controllers_dict[symbol].set_value(1, False) - processor.controllers_dict[symbol].set_readonly(True) - symbol += f":{loop}" - processor.controllers_dict[symbol].set_value(1, False) - processor.controllers_dict[symbol].set_readonly(True) - - except Exception as e: - logging.error(e) - #self.processors[0].status = self.SL_STATES[self.state]['icon'] - - def select_loop(self, loop, send=False, wrap=False): - try: - processor = self.processors[0] - except IndexError: - return - if wrap and loop >= self.loop_count: - loop = 0 - if loop < 0 or loop >= self.loop_count: - return # TODO: Handle -1 == all loops - self.selected_loop = int(loop) - self.monitors_dict['state'] = self.state[self.selected_loop] - self.monitors_dict['next_state'] = self.next_state[self.selected_loop] - self.monitors_dict['waiting'] = self.waiting[self.selected_loop] - self.update_state(self.selected_loop) - processor.controllers_dict['selected_loop_num'].set_value(loop + 1, False) - self.adjust_controller_bindings() - if send and self.osc_server: - self.osc_server.send(self.osc_target, '/set', ('s', 'selected_loop_num'), ('f', self.selected_loop)) - processor.refresh_controllers() - self.state_manager.send_cuia("refresh_screen", ["control"]) - zynsigman.send_queued(zynsigman.S_GUI, zynsigman.SS_GUI_CONTROL_MODE, mode='control') - - def prev_loop(self): - self.select_loop(self.selected_loop - 1, True) - - def next_loop(self): - self.select_loop(self.selected_loop + 1, True) - - def undo(self): - self.processors[0].controllers_dict['undo/redo'].nudge(-1) - - def redo(self): - self.processors[0].controllers_dict['undo/redo'].nudge(1) - - # --------------------------------------------------------------------------- - # API methods - # --------------------------------------------------------------------------- + # --------------------------------------------------------------------------- + # Config variables + # --------------------------------------------------------------------------- + SL_PORT = ServerPort["sooperlooper_osc"] + MAX_LOOPS = 6 + + # SL_LOOP_SEL_PARAM act on the selected loop - send with osc command /sl/#/set where #=-3 for selected or index of loop (0..5) + SL_LOOP_SEL_PARAM = [ + 'record', + 'overdub', + 'multiply', + 'replace', + 'substitute', + 'insert', + 'trigger', + 'mute', + 'oneshot', + 'pause', + 'reverse', + 'single_pedal' + ] + + # SL_LOOP_PARAMS act on individual loops - sent with osc command /sl/#/set + SL_LOOP_PARAMS = [ + 'feedback', # range 0 -> 1 + #'dry', # range 0 -> 1 + 'wet', # range 0 -> 1 + #'input_gain', # range 0 -> 1 + 'rate', # range 0.25 -> 4.0 + #'scratch_pos', # range 0 -> 1 + #'delay_trigger', # any changes + #'quantize', # 0 = off, 1 = cycle, 2 = 8th, 3 = loop + #'round', # 0 = off, not 0 = on + #'redo_is_tap' # 0 = off, not 0 = on + 'sync', # 0 = off, not 0 = on + 'playback_sync', # 0 = off, not 0 = on + #'use_rate', # 0 = off, not 0 = on + #'fade_samples', # 0 -> ... + 'use_feedback_play', # 0 = off, not 0 = on + #'use_common_ins', # 0 = off, not 0 = on + #'use_common_outs', # 0 = off, not 0 = on + #'relative_sync', # 0 = off, not 0 = on + #'use_safety_feedback', # 0 = off, not 0 = on + #'pan_1', # range 0 -> 1 + #'pan_2', # range 0 -> 1 + #'pan_3', # range 0 -> 1 + #'pan_4', # range 0 -> 1 + #'input_latency', # range 0 -> ... + #'output_latency', # range 0 -> ... + #'trigger_latency', # range 0 -> ... + #'autoset_latency', # 0 = off, not 0 = on + #'mute_quantized', # 0 = off, not 0 = on + #'overdub_quantized', # 0 == off, not 0 = on + #'replace_quantized', # 0 == off, not 0 = on (undocumented) + #'discrete_prefader', # 0 == off, not 0 = on + #'next_state,' # same as state + 'stretch_ratio', # 0.5 -> 4.0 (undocumented) + #'tempo_stretch', # 0 = off, not 0 = on (undocumented) + 'pitch_shift' # -12 -> 12 (undocumented) + ] + + # SL_LOOP_GLOBAL_PARAMS act on all loops - sent with osc command /sl/-1/set + SL_LOOP_GLOBAL_PARAMS = [ + 'rec_thresh', # range 0 -> 1 + 'round', # 0 = off, not 0 = on + 'relative_sync', # 0 = off, not 0 = on + 'quantize', # 0 = off, 1 = cycle, 2 = 8th, 3 = loop + 'mute_quantized', # 0 = off, not 0 = on + 'overdub_quantized', # 0 == off, not 0 = on + 'replace_quantized', # 0 == off, not 0 = on (undocumented) + ] + + # SL_GLOBAL_PARAMS act on whole engine - sent with osc command /set + SL_GLOBAL_PARAMS = [ + #'tempo', # bpm + #'eighth_per_cycle', + 'dry', # range 0 -> 1 affects common input passthru + #'wet', # range 0 -> 1 affects common output level + 'input_gain', # range 0 -> 1 affects common input gain + 'sync_source', # -3 = internal, -2 = midi, -1 = jack, 0 = none, # > 0 = loop number (1 indexed) + #'tap_tempo', # any changes + #'save_loop', # any change triggers quick save, be careful + #'auto_disable_latency', # when 1, disables compensation when monitoring main inputs + #'select_next_loop', # any changes + #'select_prev_loop', # any changes + #'select_all_loops', # any changes + 'selected_loop_num', # -1 = all, 0->N selects loop instances (first loop is 0, etc) + #'smart_eighths', # 0 = off, not 0 = on (undocumented) + ] + + SL_MONITORS = [ + 'rate_output', # Used to detect direction but must use register_auto_update + 'in_peak_meter', # absolute float sample value 0.0 -> 1.0 (or higher) + #'out_peak_meter', # absolute float sample value 0.0 -> 1.0 (or higher) + #'loop_len', # in seconds + #'loop_pos', # in seconds + #'cycle_len', # in seconds + #'free_time', # in seconds + #'total_time', # in seconds + #'is_soloed', # 1 if soloed, 0 if not + ] + + SL_STATES = { + # Dictionary of SL states with indication of which controllers are on/off in this SL state + SL_STATE_UNKNOWN: { + 'name': 'unknown', + 'ctrl_off': [], + 'ctrl_on': [], + 'next_state': False, + 'icon': '' + }, + SL_STATE_OFF: { + 'name': 'off', + 'ctrl_off': ['mute'], + 'ctrl_on': [], + 'next_state': False, + 'icon': '' + }, + SL_STATE_REC_STARTING: { + 'name': 'rec starting...', + 'ctrl_off': ['overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], + 'ctrl_on': ['record'], + 'next_state': False, + 'icon': '\u23EF' + }, + SL_STATE_RECORDING: { + 'name': 'recording', + 'ctrl_off': ['overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], + 'ctrl_on': ['record'], + 'next_state': False, + 'icon': '\u26AB' + }, + SL_STATE_REC_STOPPING: { + 'name': 'rec stopping...', + 'ctrl_off': ['overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], + 'ctrl_on': ['record'], + 'next_state': False, + 'icon': '\u23EF' + }, + SL_STATE_PLAYING: { + 'name': 'playing', + 'ctrl_off': ['record','overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], + 'ctrl_on': [], + 'next_state': False, + 'icon': '\uF04B' + }, + SL_STATE_OVERDUBBING: { + 'name': 'overdubbing', + 'ctrl_off': ['record', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], + 'ctrl_on': ['overdub'], + 'next_state': False, + 'icon': '\u26AB' + }, + SL_STATE_MULTIPLYING: { + 'name': 'multiplying', + 'ctrl_off': ['record', 'overdub', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], + 'ctrl_on': ['multiply'], + 'next_state': True, + 'icon': '\u26AB' + }, + SL_STATE_INSERTING: { + 'name': 'inserting', + 'ctrl_off': ['record', 'overdub', 'multiply', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], + 'ctrl_on': ['insert'], + 'next_state': True, + 'icon': '\u26AB' + }, + SL_STATE_REPLACING: { + 'name': 'replacing', + 'ctrl_off': ['record', 'overdub', 'insert', 'multiply', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], + 'ctrl_on': ['replace'], + 'next_state': True, + 'icon': '\u26AB' + }, + SL_STATE_DELAYING: { + 'name': 'delaying', + 'ctrl_off': [], + 'ctrl_on': [], + 'next_state': False, + 'icon': 'delay' + }, + SL_STATE_MUTED: { + 'name': 'muted', + 'ctrl_off': ['record', 'overdub', 'insert', 'multiply', 'replace', 'substitute', 'pause', 'trigger', 'oneshot'], + 'ctrl_on': ['mute'], + 'next_state': False, + 'icon': 'mute' + }, + SL_STATE_SCRATCHING: { + 'name': 'scratching', + 'ctrl_off': ['record', 'overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger', 'oneshot'], + 'ctrl_on': [], + 'next_state': False, + 'icon': 'scratch' + }, + SL_STATE_PLAYING_ONCE: { + 'name': 'playing once', + 'ctrl_off': ['record', 'overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'trigger'], + 'ctrl_on': ['oneshot'], + 'next_state': True, + 'icon': '\uF04B' + }, + SL_STATE_SUBSTITUTING: { + 'name': 'substituting', + 'ctrl_off': ['record', 'overdub', 'multiply', 'insert', 'replace', 'pause', 'mute', 'trigger','oneshot'], + 'ctrl_on': ['substitute'], + 'next_state': True, + 'icon': '\u26AB' + }, + SL_STATE_PAUSED: { + 'name': 'paused', + 'ctrl_off': ['record', 'overdub', 'insert', 'multiply', 'replace', 'substitute', 'mute', 'trigger', 'oneshot'], + 'ctrl_on': ['pause'], + 'next_state': False, + 'icon': '\u23F8'}, + SL_STATE_UNDO_ALL: { + 'name': 'undo all', + 'ctrl_off': [], + 'ctrl_on': [], + 'next_state': False, + 'icon': '' + }, + SL_STATE_TRIGGER_PLAY: { + 'name': 'trigger play...', + 'ctrl_off': ['record', 'overdub', 'multiply', 'insert', 'replace', 'substitute', 'pause', 'mute', 'oneshot'], + 'ctrl_on': ['trigger'], + 'next_state': False, + 'icon': '' + }, + SL_STATE_UNDO: { + 'name': 'undo', + 'ctrl_off': [], + 'ctrl_on': [], + 'next_state': False, + 'icon': '' + }, + SL_STATE_REDO: { + 'name': 'redo', + 'ctrl_off': [], + 'ctrl_on': [], + 'next_state': False, + 'icon': '' + }, + SL_STATE_REDO_ALL: { + 'name': 'redo all', + 'ctrl_off': [], + 'ctrl_on': [], + 'next_state': False, + 'icon': '' + }, + SL_STATE_OFF_MUTED: { + 'name': 'off muted', + 'ctrl_off': ['record', 'overdub', 'insert', 'multiply', 'replace', 'substitute', 'pause', 'trigger', 'oneshot'], + 'ctrl_on': ['mute'], + 'next_state': False, + 'icon': 'mute' + } + } + + # --------------------------------------------------------------------------- + # Initialization + # --------------------------------------------------------------------------- + + def __init__(self, state_manager=None): + super().__init__(state_manager) + self.name = "SooperLooper" + self.nickname = "SL" + self.jackname = "sooperlooper" + self.type = "Audio Effect" + + self.osc_target_port = self.SL_PORT + + # Load custom MIDI bindings + custom_slb_fpath = self.config_dir + "/sooperlooper/zynthian.slb" + if os.path.exists(custom_slb_fpath): + logging.info(f"loading sooperlooper custom MIDI bindings: {custom_slb_fpath}") + else: + custom_slb_fpath = None + + # Build SL command line + #if self.config_remote_display(): + # self.command = ["slgui", "-l 0", f"-P {self.osc_target_port}", f"-J {self.jackname}"] + #else: + #self.command = ["sooperlooper", "-q", "-l 0", "-D no", f"-p {self.osc_target_port}", f"-j {self.jackname}"] + self.command = ["sooperlooper", "-q", "-D no", f"-p {self.osc_target_port}", f"-j {self.jackname}"] + + if custom_slb_fpath: + self.command += ["-m", custom_slb_fpath] + + self.state = [-1] * self.MAX_LOOPS # Current SL state for each loop + self.next_state = [-1] * self.MAX_LOOPS # Next SL state for each loop (-1 if no state change pending) + self.waiting = [0] * self.MAX_LOOPS # 1 if a change of state is pending + self.selected_loop = None + self.loop_count = 1 + self.channels = 2 + self.selected_loop_cc_binding = True # True for MIDI CC to control selected loop. False to target all loops + + ui_dir = os.environ.get('ZYNTHIAN_UI_DIR', "/zynthian/zynthian-ui") + self.custom_gui_fpath = f"{ui_dir}/zyngui/zynthian_widget_sooperlooper.py" + self.monitors_dict = { + "state": 0, + "next_state": -1, + "loop_count": 0 + } + self.pedal_time = 0 # Time single pedal was asserted + self.pedal_taps = 0 # Quantity of taps on single pedal + self.single_pedal_timer = None # Timer used for long pedal press + + # MIDI Controllers + loop_labels = [] + for i in range(self.MAX_LOOPS): + loop_labels.append(str(i + 1)) + self._ctrls = [ + #symbol, {options}, midi_cc + ['record', {'name': 'record', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], + ['overdub', {'name': 'overdub', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], + ['multiply', {'name': 'multiply', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], + ['replace', {'name': 'replace', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], + ['substitute', {'name': 'substitute', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], + ['insert', {'name': 'insert', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], + ['undo/redo', {'value': 1, 'labels': ['<', '<>', '>']}], + ['next_loop', {'name': 'next loop', 'value': 0, 'value_max': 127, 'labels': ['>', '']}], + ['trigger', {'name': 'trigger', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], + ['mute', {'name': 'mute', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], + ['oneshot', {'name': 'oneshot', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], + ['pause', {'name': 'pause', 'value': 0, 'value_max': 1, 'labels': ['off', 'on'], 'is_toggle': True}], + ['reverse', {'name': 'direction', 'value': 0, 'labels': ['reverse', 'forward'], 'ticks':[1, 0], 'is_toggle': True}], + ['rate', {'name': 'speed', 'value': 1.0, 'value_min': 0.25, 'value_max': 4.0, 'is_integer': False, 'nudge_factor': 0.01}], + ['stretch_ratio', {'name': 'stretch', 'value': 1.0, 'value_min': 0.5, 'value_max': 4.0, 'is_integer': False, 'nudge_factor': 0.01}], + ['pitch_shift', {'name': 'pitch', 'value': 0.0, 'value_min': -12, 'value_max': 12, 'is_integer': False, 'nudge_factor': 0.05}], + ['sync_source', {'name': 'sync to', 'value': 1, 'value_min': -1, 'value_max': 1, 'labels': ['Host', 'None', 'Loop1'], 'is_integer': True}], + ['sync', {'name': 'enable sync', 'value': 1, 'value_max': 1, 'labels': ['off', 'on']}], + ['eighth_per_cycle', {'name': '8th/cycle', 'value': 16, 'value_min': 1, 'value_max': 600}], # TODO: What makes sense for max val? + ['quantize', {'value': 1, 'value_max': 3, 'labels': ['off', 'cycle', '8th', 'loop']}], + ['mute_quantized', {'name': 'mute quant', 'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], + ['overdub_quantized', {'name': 'overdub quant', 'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], + ['replace_quantized', {'name': 'replace quant', 'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], + ['round', {'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], + ['relative_sync', {'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], + ['smart_eighths', {'name': 'auto 8th', 'value': 1, 'value_max': 1, 'labels': ['off', 'on']}], + ['playback_sync', {'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], + ['use_feedback_play', {'name': 'play feedback', 'value': 0, 'value_max': 1, 'labels': ['off', 'on']}], + ['rec_thresh', {'name': 'threshold', 'value': 0.0, 'value_max': 1.0, 'is_integer': False, 'is_logarithmic': True}], + ['feedback', {'value': 1.0, 'value_max': 1.0, 'is_integer': False, 'is_logarithmic': True}], + ['dry', {'value': 1.0, 'value_max': 1.0, 'is_integer': False, 'is_logarithmic': True}], + ['wet', {'value': 1.0, 'value_max': 1.0, 'is_integer': False, 'is_logarithmic': True}], + ['input_gain', {'name': 'input gain', 'value': 1.0, 'value_max': 1.0, 'is_integer': False, 'is_logarithmic': True}], + ['loop_count', {'name': 'loop count', 'value': 1, 'value_min': 1, 'value_max': self.MAX_LOOPS}], + ['selected_loop_num', {'name': 'selected loop', 'value': 1, 'value_min': 1, 'value_max': 6}], + ['single_pedal', {'name': 'single pedal', 'value': 0, 'value_max': 1, 'labels': ['>', '<'], 'is_toggle': True}], + ['selected_loop_cc', {'name': 'midi cc to selected loop', 'value': 127, 'labels':['off', 'on']}], + ['load_file', {'name': 'load file', 'is_path': True, 'path_file_types': ["wav"], 'path_dir_names': []}] + ] + + # Controller Screens + self._ctrl_screens = [ + ['Loop record 1', ['record', 'overdub', 'multiply', 'undo/redo']], + ['Loop record 2', ['replace', 'substitute', 'insert', 'undo/redo']], + ['Loop control', ['trigger', 'oneshot', 'mute', 'pause']], + ['Loop time/pitch', ['reverse', 'rate', 'stretch_ratio', 'pitch_shift']], + ['Loop levels', ['wet', 'dry', 'feedback', 'selected_loop_num']], + ['Global loop', ['selected_loop_num', 'loop_count', 'next_loop', 'single_pedal']], + ['Global levels', ['rec_thresh', 'input_gain', 'load_file']], + ['Global quantize', ['quantize', 'mute_quantized', 'overdub_quantized', 'replace_quantized']], + ['Global sync 1', ['sync_source', 'sync', 'playback_sync', 'relative_sync']], + ['Global sync 2', ['round', 'use_feedback_play', 'selected_loop_cc']] + ] + + self.start() + + # --------------------------------------------------------------------------- + # Subproccess Management & IPC + # --------------------------------------------------------------------------- + + def start(self): + logging.debug(f"Starting SooperLooper with command: {self.command}") + self.osc_init() + self.proc = Popen(self.command, stdout=DEVNULL, stderr=DEVNULL, env=self.command_env, cwd=self.command_cwd) + sleep(1) # TODO: Cludgy wait - maybe should perform periodic check for server until reachable + + # Register for common events from sooperlooper server - request changes to the currently selected loop + for symbol in self.SL_MONITORS: + self.osc_server.send(self.osc_target, '/sl/-3/register_auto_update', ('s', symbol), ('i', 100), ('s', self.osc_server_url), ('s', '/monitor')) + for symbol in self.SL_LOOP_PARAMS: + self.osc_server.send(self.osc_target, '/sl/-3/register_auto_update', ('s', symbol), ('i', 100), ('s', self.osc_server_url), ('s', '/control')) + for symbol in self.SL_LOOP_GLOBAL_PARAMS: + # Register for tallies of commands sent to all channels + self.osc_server.send(self.osc_target, '/sl/-3/register_auto_update', ('s', symbol), ('i', 100), ('s', self.osc_server_url), ('s', '/control')) + + # Register for global events from sooperlooper + for symbol in self.SL_GLOBAL_PARAMS: + self.osc_server.send(self.osc_target, '/register_auto_update', ('s', symbol), ('i', 100), ('s', self.osc_server_url), ('s', '/control')) + self.osc_server.send(self.osc_target, '/register', ('s', self.osc_server_url), ('s', '/info')) + + # Request current quantity of loops + self.osc_server.send(self.osc_target, '/ping', ('s', self.osc_server_url), ('s', '/info')) + + def stop(self): + if self.proc: + try: + logging.info("Stoping Engine " + self.name) + self.proc.terminate() + try: + self.proc.wait(0.2) + except: + self.proc.kill() + self.proc = None + except Exception as err: + logging.error(f"Can't stop engine {self.name} => {err}") + self.osc_end() + + # --------------------------------------------------------------------------- + # Processor Management + # --------------------------------------------------------------------------- + + # --------------------------------------------------------------------------- + # MIDI Channel Management + # --------------------------------------------------------------------------- + + # --------------------------------------------------------------------------- + # Bank Management + # --------------------------------------------------------------------------- + + # No bank support for sooperlooper + def get_bank_list(self, processor=None): + return [("", None, "", None)] + + # --------------------------------------------------------------------------- + # Preset Management + # --------------------------------------------------------------------------- + + def get_preset_list(self, bank, processor=None): + presets = self.get_filelist(f"{self.data_dir}/presets/sooperlooper", "slsess") + presets += self.get_filelist(f"{self.my_data_dir}/presets/sooperlooper", "slsess") + return presets + + def set_preset(self, processor, preset, preload=False): + if preload or self.osc_server is None: + return False + self.osc_server.send(self.osc_target, '/load_session', ('s', preset[0]), ('s', self.osc_server_url), ('s', '/error')) + sleep(0.5) # Wait for session to load to avoid consequent controller change conflicts + + # Request quantity of loops in session + self.osc_server.send(self.osc_target, '/ping', ('s', self.osc_server_url), ('s', '/info')) + + for symbol in self.SL_MONITORS: + self.osc_server.send(self.osc_target, '/sl/-3/get', ('s', symbol), ('s', self.osc_server_url), ('s', '/monitor')) + for symbol in self.SL_LOOP_PARAMS: + self.osc_server.send(self.osc_target, '/sl/-3/get', ('s', symbol), ('s', self.osc_server_url), ('s', '/control')) + for symbol in self.SL_LOOP_GLOBAL_PARAMS: + self.osc_server.send(self.osc_target, '/sl/-3/get', ('s', symbol), ('s', self.osc_server_url), ('s', '/control')) + for symbol in self.SL_GLOBAL_PARAMS: + self.osc_server.send(self.osc_target, '/get', ('s', symbol), ('s', self.osc_server_url), ('s', '/control')) + + sleep(0.5) # Wait for controls to update + + # Start loops (muted) to synchronise + self.osc_server.send(self.osc_target, '/sl/-1/hit', ('s', 'mute')) + return True + + def preset_exists(self, bank_info, preset_name): + return os.path.exists(f"{self.my_data_dir}/presets/sooperlooper/{preset_name}.slsess") + + def save_preset(self, bank_name, preset_name): + if self.osc_server is None: + return + path = f"{self.my_data_dir}/presets/sooperlooper" + if not os.path.exists(path): + try: + os.mkdir(path) + except Exception as e: + logging.warning(f"Failed to create SooperLooper user preset directory: {e}") + uri = f"{path}/{preset_name}.slsess" + # Undocumented feature: set 4th (int) parameter to 1 to save loop audio + self.osc_server.send(self.osc_target, '/save_session', ('s', uri), ('s', self.osc_server_url), ('s', '/error'), ('i', 1)) + return uri + + def delete_preset(self, bank, preset): + try: + os.remove(preset[0]) + wavs = glob(f"{preset[0]}_loop*.wav") + for file in wavs: + os.remove(file) + except Exception as e: + logging.debug(e) + + def rename_preset(self, bank_info, preset, new_name): + try: + os.rename(preset[0], f"{self.my_data_dir}/presets/sooperlooper/{new_name}.slsess") + return True + except Exception as e: + logging.debug(e) + return False + + # ---------------------------------------------------------------------------- + # Controllers Management + # ---------------------------------------------------------------------------- + + def get_controllers_dict(self, processor): + if not processor.controllers_dict: + for ctrl in self._ctrls: + ctrl[1]['processor'] = processor + if ctrl[0] in self.SL_LOOP_SEL_PARAM: + # Create a zctrl for each loop + for i in range(self.MAX_LOOPS): + name = ctrl[1].get('name') + specs = ctrl[1].copy() + specs['name'] = f"{name} ({i + 1})" + zctrl = zynthian_controller(self, f"{ctrl[0]}:{i}", specs) + processor.controllers_dict[zctrl.symbol] = zctrl + zctrl = zynthian_controller(self, ctrl[0], ctrl[1]) + processor.controllers_dict[zctrl.symbol] = zctrl + return processor.controllers_dict + + def adjust_controller_bindings(self): + if self.selected_loop_cc_binding: + self._ctrl_screens[0][1] = [f'record', f'overdub', f'multiply', 'undo/redo'] + self._ctrl_screens[1][1] = [f'replace', f'substitute', f'insert', 'undo/redo'] + self._ctrl_screens[2][1] = [f'trigger', f'oneshot', f'mute', f'pause'] + self._ctrl_screens[3][1][0] = f'reverse' + self._ctrl_screens[5][1][3] = f'single_pedal' + else: + self._ctrl_screens[0][1] = [f'record:{self.selected_loop}', f'overdub:{self.selected_loop}', f'multiply:{self.selected_loop}', 'undo/redo'] + self._ctrl_screens[1][1] = [f'replace:{self.selected_loop}', f'substitute:{self.selected_loop}', f'insert:{self.selected_loop}', 'undo/redo'] + self._ctrl_screens[2][1] = [f'trigger:{self.selected_loop}', f'oneshot:{self.selected_loop}', f'mute:{self.selected_loop}', f'pause:{self.selected_loop}'] + self._ctrl_screens[3][1][0] = f'reverse:{self.selected_loop}' + self._ctrl_screens[5][1][3] = f'single_pedal:{self.selected_loop}' + return + + def send_controller_value(self, zctrl): + try: + processor = self.processors[0] + except IndexError: + return + if zctrl.symbol == "selected_loop_cc": + self.selected_loop_cc_binding = zctrl.value != 0 + self.adjust_controller_bindings() + for symbol in self.SL_LOOP_SEL_PARAM: + self.osc_server.send(self.osc_target, '/sl/-1/get', ('s', symbol), ('s', self.osc_server_url), ('s', '/control')) + processor.refresh_controllers() + self.state_manager.send_cuia("refresh_screen", ["control"]) + return + elif zctrl.symbol == "load_file": + self.osc_server.send(self.osc_target, f"/sl/{self.selected_loop}/load_loop", ("s", zctrl.value), ('s', self.osc_server_url), ('s', '/error')) + zctrl.value = os.path.dirname(zctrl.value) + return + + if ":" in zctrl.symbol: + if processor.controllers_dict["selected_loop_cc"].value: + return + symbol, loop = zctrl.symbol.split(":") + loop = int(loop) + else: + symbol = zctrl.symbol + loop = self.selected_loop + if not processor.controllers_dict["selected_loop_cc"].value and zctrl.symbol in self.SL_LOOP_SEL_PARAM: + return + + if self.osc_server is None or symbol in ['oneshot', 'trigger'] and zctrl.value == 0: + # Ignore off signals + return + elif symbol in ("mute", "pause"): + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', symbol)) + elif symbol == 'single_pedal': + """ Single pedal logic + Idle -> Record + Record->Play + Play->Overdub + Overdub->Play + Double press: pause + Triple press or double press and hold: Clear + Press once and hold to record/overdub until release + """ + ts = monotonic() + pedal_dur = ts - self.pedal_time + self.pedal_time = ts + if zctrl.value: + # Pedal push + if pedal_dur < 0.5: + self.pedal_taps += 1 + else: + self.pedal_taps = 1 + + try: + self.single_pedal_timer.cancel() + self.single_pedal_timer = None + except: + pass + + match self.pedal_taps: + case 1: + # Single tap + if self.state[loop] in (SL_STATE_PLAYING, SL_STATE_OVERDUBBING, SL_STATE_MUTED): + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'overdub')) + if self.state[loop] in (SL_STATE_UNKNOWN, SL_STATE_OFF, SL_STATE_OFF_MUTED): + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'record')) + elif self.state[loop] == SL_STATE_RECORDING: + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'record')) + elif self.state[loop] == SL_STATE_PAUSED: + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'trigger')) + case 2: + # Double tap + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'pause')) + case 3: + # Triple tap + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'undo_all')) + self.single_pedal_timer = Timer(1.5, self.single_pedal_cb) + self.single_pedal_timer.start() + else: + # Pedal release: so check loop state, pedal press duration, etc. + try: + self.single_pedal_timer.cancel() + self.single_pedal_timer = None + except: + pass + if pedal_dur > 1.5: + # Handle press and hold record + if self.state[loop] == SL_STATE_OVERDUBBING: + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'overdub')) + elif self.state[loop] == SL_STATE_RECORDING: + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'record')) + + elif symbol == 'selected_loop_num': + self.select_loop(zctrl.value - 1, True) + elif symbol in self.SL_LOOP_PARAMS: # Selected loop + self.osc_server.send(self.osc_target, f'/sl/{loop}/set', ('s', symbol), ('f', zctrl.value)) + elif symbol in self.SL_LOOP_GLOBAL_PARAMS: # All loops + self.osc_server.send(self.osc_target, '/sl/-1/set', ('s', symbol), ('f', zctrl.value)) + elif symbol in self.SL_GLOBAL_PARAMS: # Global params + self.osc_server.send(self.osc_target, '/set', ('s', symbol), ('f', zctrl.value)) + + elif symbol == 'next_loop': + # Use single controller to perform next (CW) + if zctrl.value: + self.select_loop(self.selected_loop + 1, True, True) + zctrl.set_value(0, False) + elif zctrl.is_toggle: + # Use is_toggle to indicate the SL function is a toggle, i.e. press to engage, press to release + if symbol == 'record' and zctrl.value == 0 and self.state[loop] == SL_STATE_REC_STARTING: + # TODO: Implement better toggle of pending state + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'undo')) + return + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', symbol)) + #if symbol == 'trigger': + #zctrl.set_value(0, False) # Make trigger a pulse + elif symbol == 'undo/redo': + # Use single controller to perform undo (CCW) and redo (CW) + if zctrl.value == 0: + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'undo')) + elif zctrl.value == 2: + self.osc_server.send(self.osc_target, f'/sl/{loop}/hit', ('s', 'redo')) + zctrl.set_value(1, False) + elif symbol == 'loop_count': + for loop in range(self.loop_count, zctrl.value): + self.osc_server.send(self.osc_target, '/loop_add', ('i', self.channels), ('f', 30), ('i', 0)) + if zctrl.value < self.loop_count: + # Don't remove loops - let GUI offer option to (confirm and) remove + zctrl.set_value(self.loop_count, False) + self.monitors_dict['loop_del'] = True + + def single_pedal_cb(self): + match self.pedal_taps: + case 2: + # Double tap + hold - clear loop + self.osc_server.send(self.osc_target, f'/sl/-3/hit', ('s', 'undo_all')) + self.pedal_taps = 0 + self.single_pedal_timer = None + + def get_monitors_dict(self): + return self.monitors_dict + + # ---------------------------------------------------------------------------- + # OSC Managament + # ---------------------------------------------------------------------------- + + def cb_osc_all(self, path, args, types, src): + if self.osc_server is None: + return + try: + processor = self.processors[0] + if path == '/state': + # args: i:Loop index, s:control, f:value + logging.debug("Loop State: %d %s=%0.1f", args[0], args[1], args[2]) + if args[0] < 0 or args[0] >= self.MAX_LOOPS: + return + state = int(args[2]) + loop = args[0] + if args[1] == 'next_state': + self.next_state[loop] = state + elif args[1] == 'state': + self.state[loop] = state + if state in [0, 4]: + self.next_state[loop] = -1 + elif args[1] == 'waiting': + self.waiting[loop] = state + + if self.next_state[loop] == self.state[loop]: + self.next_state[loop] = -1 + + self.monitors_dict[f"state_{loop}"] = self.state[loop] + self.monitors_dict[f"next_state_{loop}"] = self.next_state[loop] + self.monitors_dict[f"waiting_{loop}"] = self.waiting[loop] + if self.selected_loop == loop: + self.monitors_dict['state'] = self.state[loop] + self.monitors_dict['next_state'] = self.next_state[loop] + self.monitors_dict['waiting'] = self.waiting[loop] + self.update_state(loop) + + elif path == '/info': + # args: s:hosturl s:version i:loopcount + #logging.debug("Info: from %s ver: %s loops: %d", args[0], args[1], args[2]) + self.sl_version = args[1] + loop_count_changed = int(args[2]) - self.loop_count # +/- quantity of added/removed loops + self.loop_count = int(args[2]) + if loop_count_changed: + labels = ['Host', 'None'] + for loop in range(self.loop_count): + labels.append(f"Loop {loop + 1}") + try: + processor.controllers_dict['sync_source'].set_options({'labels': labels, 'ticks':[], 'value_max': self.loop_count}) + processor.controllers_dict['loop_count'].set_value(self.loop_count, False) + processor.controllers_dict['selected_loop_num'].value_max = self.loop_count + except: + pass # zctrls may not yet be initialised + if loop_count_changed > 0: + for i in range(loop_count_changed): + self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'loop_pos'), ('i', 100), ('s', self.osc_server_url), ('s', '/monitor')) + self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'loop_len'), ('i', 100), ('s', self.osc_server_url), ('s', '/monitor')) + self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'mute'), ('i', 100), ('s', self.osc_server_url), ('s', '/monitor')) + self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'state'), ('i', 100), ('s', self.osc_server_url), ('s', '/state')) + self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'next_state'), ('i', 100), ('s', self.osc_server_url), ('s', '/state')) + self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/register_auto_update", ('s', 'waiting'), ('i', 100), ('s', self.osc_server_url), ('s', '/state')) + if self.loop_count > 1: + # Set defaults for new loops + self.osc_server.send(self.osc_target, f"/sl/{self.loop_count - 1 - i}/set", ('s', 'sync'), ('f', 1)) + self.select_loop(self.loop_count - 1, True) + + self.osc_server.send(self.osc_target, '/get', ('s', 'sync_source'), ('s', self.osc_server_url), ('s', '/control')) + if self.selected_loop is not None and self.selected_loop > self.loop_count: + self.select_loop(self.loop_count - 1, True) + + self.monitors_dict['loop_count'] = self.loop_count + self.monitors_dict['version'] = self.sl_version + elif path == '/control': + # args: i:Loop index, s:control, f:value + #logging.debug("Control: Loop %d %s=%0.2f", args[0], args[1], args[2]) + self.monitors_dict[args[1]] = args[2] + if args[1] == 'selected_loop_num': + self.select_loop(args[2]) + return + try: + processor.controllers_dict[args[1]].set_value(args[2], False) + processor.controllers_dict[f"{args[1]}:{self.selected_loop}"].set_value(args[2], False) + except Exception as e: + pass + #logging.warning("Unsupported tally (or zctrl not yet configured) %s (%f)", args[1], args[2]) + elif path == '/monitor': + # args: i:Loop index, s:control, f:value + # Handle events registered for selected loop + #logging.debug("Monitor: Loop %d %s=%0.2f", args[0], args[1], args[2]) + if args[0] == -3: + if args[1] == 'rate_output': + try: + if args[2] < 0.0: + processor.controllers_dict['reverse'].set_value(1, False) + else: + processor.controllers_dict['reverse'].set_value(0, False) + except: + pass # zctrls may not yet be initialised + self.monitors_dict[args[1]] = args[2] + else: + self.monitors_dict[f"{args[1]}_{args[0]}"] = args[2] + elif path == 'error': + logging.error(f"SooperLooper daemon error: {args[0]}") + except Exception as e: + logging.warning(e) + + # --------------------------------------------------------------------------- + # Specific functions + # --------------------------------------------------------------------------- + + # Update 'state' controllers of loop + def update_state(self, loop): + try: + processor = self.processors[0] + except: + return + try: + current_state = self.state[loop] + #logging.warning(f"loop: {loop} state: {current_state}") + # Turn off all controllers that are off in this state + for symbol in self.SL_STATES[current_state]['ctrl_off']: + if symbol in self.SL_LOOP_SEL_PARAM: + if self.selected_loop == loop: + processor.controllers_dict[symbol].set_readonly(False) + processor.controllers_dict[symbol].set_value(0, False) + symbol += f":{loop}" + processor.controllers_dict[symbol].set_readonly(False) + processor.controllers_dict[symbol].set_value(0, False) + # Turn on all controllers that are on in this state + for symbol in self.SL_STATES[current_state]['ctrl_on']: + if symbol in self.SL_LOOP_SEL_PARAM: + if self.selected_loop == loop: + processor.controllers_dict[symbol].set_readonly(False) + processor.controllers_dict[symbol].set_value(1, False) + symbol += f":{loop}" + processor.controllers_dict[symbol].set_readonly(False) + processor.controllers_dict[symbol].set_value(1, False) + next_state = self.next_state[loop] + # Set next_state for controllers that are part of logical sequence + if self.SL_STATES[next_state]['next_state']: + for symbol in self.SL_STATES[next_state]['ctrl_on']: + if symbol in self.SL_LOOP_SEL_PARAM: + if self.selected_loop == loop: + processor.controllers_dict[symbol].set_value(1, False) + processor.controllers_dict[symbol].set_readonly(True) + symbol += f":{loop}" + processor.controllers_dict[symbol].set_value(1, False) + processor.controllers_dict[symbol].set_readonly(True) + + except Exception as e: + logging.error(e) + #self.processors[0].status = self.SL_STATES[self.state]['icon'] + + def select_loop(self, loop, send=False, wrap=False): + try: + processor = self.processors[0] + except IndexError: + return + if wrap and loop >= self.loop_count: + loop = 0 + if loop < 0 or loop >= self.loop_count: + return # TODO: Handle -1 == all loops + self.selected_loop = int(loop) + self.monitors_dict['state'] = self.state[self.selected_loop] + self.monitors_dict['next_state'] = self.next_state[self.selected_loop] + self.monitors_dict['waiting'] = self.waiting[self.selected_loop] + self.update_state(self.selected_loop) + processor.controllers_dict['selected_loop_num'].set_value(loop + 1, False) + self.adjust_controller_bindings() + if send and self.osc_server: + self.osc_server.send(self.osc_target, '/set', ('s', 'selected_loop_num'), ('f', self.selected_loop)) + processor.refresh_controllers() + self.state_manager.send_cuia("refresh_screen", ["control"]) + zynsigman.send_queued(zynsigman.S_GUI, zynsigman.SS_GUI_CONTROL_MODE, mode='control') + + def prev_loop(self): + self.select_loop(self.selected_loop - 1, True) + + def next_loop(self): + self.select_loop(self.selected_loop + 1, True) + + def undo(self): + self.processors[0].controllers_dict['undo/redo'].nudge(-1) + + def redo(self): + self.processors[0].controllers_dict['undo/redo'].nudge(1) + + # --------------------------------------------------------------------------- + # API methods + # --------------------------------------------------------------------------- # ******************************************************************************* diff --git a/zyngine/zynthian_engine_tempo.py b/zyngine/zynthian_engine_tempo.py new file mode 100644 index 000000000..b9d11b377 --- /dev/null +++ b/zyngine/zynthian_engine_tempo.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian Engine (zynthian_engine_tempo) +# +# zynthian_engine implementation for Tempo control +# +# Copyright (C) 2015-2026 Fernando Moyano +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import logging +from time import monotonic +from collections import deque + +from zyncoder.zyncore import lib_zyncore +from zyngine.zynthian_engine import zynthian_engine +from zyngine.zynthian_controller import zynthian_controller +import zynautoconnect + +# ------------------------------------------------------------------------------ +# Tempo Engine Class +# ------------------------------------------------------------------------------ + + +class zynthian_engine_tempo(zynthian_engine): + + # --------------------------------------------------------------------------- + # Controllers & Screens + # --------------------------------------------------------------------------- + + _ctrl_screens = [ + ["Tempo", ["bpm", "metro_enable", "metro_volume", "ppqn"]] + ] + + # ---------------------------------------------------------------------------- + # ZynAPI variables + # ---------------------------------------------------------------------------- + + zynapi_instance = None + + # ---------------------------------------------------------------------------- + # Initialization + # ---------------------------------------------------------------------------- + + def __init__(self, state_manager=None, proc=None): + super().__init__(state_manager) + + self.type = "Tempo" + self.name = "Tempo" + self.nickname = "TP" + self.custom_gui_fpath = "/zynthian/zynthian-ui/zyngui/zynthian_widget_tempo.py" + self.processor = proc + + self.audio_out = [] + self.options['midi_chan'] = False + self.options['replace'] = False + + self.zctrls = None + + self.buttonbar_config = [ + ("toggle_audio_play", "Play\nAudio"), + ("toggle_audio_record", "Record\nAudio"), + ("toggle_midi_play", "Play\nMIDI"), + ("toggle_midi_record", "Record\nMIDI") + ] + + # --------------------------------------------------------------------------- + # Processor Management + # --------------------------------------------------------------------------- + + def get_path(self, processor): + return self.name + + # --------------------------------------------------------------------------- + # MIDI Channel Management + # --------------------------------------------------------------------------- + + # ---------------------------------------------------------------------------- + # Bank Managament + # ---------------------------------------------------------------------------- + + def get_bank_list(self, processor=None): + return [("", None, "", None)] + + def set_bank(self, processor, bank): + return True + + # ---------------------------------------------------------------------------- + # Preset Managament + # ---------------------------------------------------------------------------- + + def get_preset_list(self, bank, processor=None): + return [("", None, "", None)] + + def set_preset(self, processor, preset, preload=False): + return True + + def cmp_presets(self, preset1, preset2): + return True + + # ---------------------------------------------------------------------------- + # Controllers Managament + # ---------------------------------------------------------------------------- + + def get_controllers_dict(self, processor=None, ctrl_list=None): + if zynautoconnect.get_ext_clock_zmip() < 0: + self._ctrl_screens = [["Tempo", ["bpm", "metro_enable", "metro_volume"]]] + else: + self._ctrl_screens = [["Tempo", ["ppqn", "metro_enable", "metro_volume"]]] + + if processor: + if not processor.controllers_dict: + processor.controllers_dict = { + "bpm": self.state_manager.zynseq.zctrl_tempo, + "metro_enable": self.state_manager.zynseq.zctrl_metro_mode, + "metro_volume": self.state_manager.zynseq.zctrl_metro_volume, + "ppqn": self.state_manager.zynseq.zctrl_ppqn + } + return processor.controllers_dict + return { + "bpm": self.state_manager.zynseq.zctrl_tempo, + "metro_enable": self.state_manager.zynseq.zctrl_metro_mode, + "metro_volume": self.state_manager.zynseq.zctrl_metro_volume, + "ppqn": self.state_manager.zynseq.zctrl_ppqn + } + + def send_controller_value(self, zctrl): + pass + + # ---------------------------------------------------------------------------- + # Special + # ---------------------------------------------------------------------------- + + +# ****************************************************************************** diff --git a/zyngine/zynthian_engine_zynaddsubfx.py b/zyngine/zynthian_engine_zynaddsubfx.py index 4229fac60..c9122fa08 100644 --- a/zyngine/zynthian_engine_zynaddsubfx.py +++ b/zyngine/zynthian_engine_zynaddsubfx.py @@ -55,8 +55,8 @@ class zynthian_engine_zynaddsubfx(zynthian_engine): # ['expression', 11, 127], ['volume', '/part$i/Pvolume', 96, 127, {'midi_cc': 7}], ['panning', '/part$i/Ppanning', 64], - ['filter cutoff', 74, 64], - ['filter resonance', 71, 64], + ['filter cutoff', 74, 64, 127, {'filter': "cutoffFrequency"}], + ['filter resonance', 71, 64, 127, {'filter': "resonance"}], ['voice limit', '/part$i/Pvoicelimit', 0, 60], ['drum mode', '/part$i/Pdrummode', 'off', 'off|on'], diff --git a/zyngine/zynthian_legacy_snapshot.py b/zyngine/zynthian_legacy_snapshot.py index 880b19b3a..e94e16af4 100644 --- a/zyngine/zynthian_legacy_snapshot.py +++ b/zyngine/zynthian_legacy_snapshot.py @@ -25,11 +25,13 @@ import logging from math import ceil -from json import JSONDecoder +from json import JSONDecoder, loads +import base64 from zyngine.zynthian_chain_manager import zynthian_chain_manager +from zyngine import zynthian_state_manager -SNAPSHOT_SCHEMA_VERSION = 3 +SNAPSHOT_SCHEMA_VERSION = 5 class zynthian_legacy_snapshot: @@ -76,6 +78,163 @@ def convert_state(self, snapshot): getattr(self, f'version_{version}')() return self.snapshot + def version_4(self): + # Convert snapshot from schema V4 to V5 + + self.snapshot.setdefault("midi", {"midi_capture": {}, "midi_playback": {}}) + try: + names = self.snapshot["midi_profile_state"].pop("port_names") + for uid, name in names.items(): + if name.lower().endswith("in"): + self.snapshot["midi"]["midi_capture"][uid] = name + else: + self.snapshot["midi"]["midi_playback"][uid] = name + except: + pass + mixer_map = {16: zynthian_state_manager.MAIN_MIXBUS_ID} # Map of "MI" mixer proc id indexed by old mixer chan + if "chains" in self.snapshot: + # Get list of used processor ids: + proc_ids = [] + for chain_config in self.snapshot["chains"].values(): + if "slots" in chain_config: + for slot in chain_config["slots"]: + for id in slot: + proc_ids.append(int(id)) + if proc_ids: + proc_ids.sort() + next_id = proc_ids[-1] + 1 + else: + next_id = 1 + + # Insert mixer processors in audio chains and remove mixer channel/fader refs + audio_procs = [] + for id, info in self.engine_info.items(): + if info["TYPE"] == "Audio Effect": + audio_procs.append(id) + for chain_id, chain_config in self.snapshot["chains"].items(): + try: + mixer_chan = chain_config.pop("mixer_chan") + if "slots" not in chain_config: + chain_config["slots"] = [] + try: + fader_pos = chain_config.pop("fader_pos") + for slot in chain_config["slots"]: + proc_type = list(slot.values())[0] + if proc_type in audio_procs: + break + fader_pos += 1 + except: + fader_pos = len(chain_config["slots"]) + if chain_id == "0": + chain_config["slots"].insert(fader_pos, {str(zynthian_state_manager.MAIN_MIXBUS_ID):"MR"}) + else: + chain_config["slots"].insert(fader_pos, {str(next_id):"MI"}) + mixer_map[int(mixer_chan)] = int(next_id) + next_id += 1 + except: + pass + if "midi_cc" in chain_config: + for cc, cfg in chain_config["midi_cc"].items(): + proc_id = int(cfg[0]) + symbol = cfg[1] + zs3["processors"].setdefault(proc_id, {"controllers":{}}) + zs3["processors"][proc_id]["controllers"].setdefault(symbol, {}) + zs3["processors"][proc_id]["controllers"][symbol].setdefault("midi_cc", [chain_id, None, int(cc), 0]) + + if "zs3" in self.snapshot: + for zs3 in self.snapshot["zs3"].values(): + try: + mixer = zs3.pop("mixer") + zs3.setdefault("chains", {}) + zs3.setdefault("processors", {}) + # convert proc_id from sting to int + for proc_id in list(zs3["processors"]): + zs3["processors"][int(proc_id)] = zs3["processors"].pop(proc_id) + for chan, proc_id in mixer_map.items(): + key = f"chan_{chan:02d}" + proc_id = int(proc_id) + if key in mixer: + for param, val in mixer[key].items(): + zs3["processors"].setdefault(proc_id, {}) + zs3["processors"][proc_id].setdefault("controllers", {}) + zs3["processors"][proc_id]["controllers"][param]={"value":val} + if "midi_learn" in mixer: + for key, conf in mixer["midi_learn"].items(): + chan, cc = key.split(",") + strip_id = conf[0] + proc_id = int(mixer_map[int(strip_id)]) + symbol = conf[1] + zs3["processors"].setdefault(proc_id, {"controllers":{}}) + zs3["processors"][proc_id]["controllers"].setdefault(symbol, {}) + zs3["processors"][proc_id]["controllers"][symbol].setdefault("midi_cc", [None, int(chan), int(cc), 0]) + except: + pass + if "chains" in zs3: + for chain_id, cfg in zs3["chains"].items(): + if "midi_cc" in cfg: + for cc, midi_cfgs in cfg["midi_cc"].items(): + for midi_cfg in midi_cfgs: + proc_id = int(midi_cfg[0]) + symbol = midi_cfg[1] + zs3["processors"].setdefault(proc_id, {"controllers":{}}) + zs3["processors"][proc_id]["controllers"].setdefault(symbol, {}) + zs3["processors"][proc_id]["controllers"][symbol].setdefault("midi_cc", [int(chain_id), None, int(cc), 0]) + """ This is not saved correctly in previous versions - no MIDI channel so ignore here. + if "midi_capture" in zs3: + for uid, cfg in zs3["midi_capture"].items(): + if "midi_cc" in cfg: + for cc, midi_cfgs in cfg["midi_cc"].items(): + for midi_cfg in midi_cfgs: + proc_id = int(midi_cfg[0]) + symbol = midi_cfg[1] + dev = zynautoconnect.get_midi_in_devid_by_uid(uid) + if dev is None: + dev_ex = 0 + else: + dev_ex = (2 ** 32 - 1) ^ (1 << dev) + zs3["processors"].setdefault(proc_id, {"controllers":{}}) + zs3["processors"][proc_id]["controllers"].setdefault(symbol, {}) + zs3["processors"][proc_id]["controllers"][symbol].setdefault("midi_cc", [None, int(chan), int(cc), dev_ex]) + """ + + + def version_3(self): + # Convert snapshot from schema V3 to V4 + + # Convert binary seq to json + + fpath = "/tmp/snapshot.zynseq" + try: + # Save RIFF data to tmp file + b64_bytes = self.snapshot["zynseq_riff_b64"].encode("utf-8") + binary_riff_data = base64.decodebytes(b64_bytes) + with open(fpath, "wb") as fh: + fh.write(binary_riff_data) + a = self.state_manager.zynseq.libseq.convertToJson(bytes(fpath, "utf-8")).decode("utf-8") + self.state_manager.zynseq.libseq.freeState() + self.snapshot["zynseq"] = loads(a) + del self.snapshot["zynseq_riff_b64"] + for scene in self.snapshot["zynseq"]["scenes"]: + num_seq = len (scene["phrases"][0]["sequences"]) + for phrase_idx, phrase in enumerate(scene["phrases"]): + for seq in range(num_seq): + try: + if phrase["sequences"][seq]: + continue + except: + if seq == 0: + del scene["phrases"][phrase_idx] + break + phrase["sequences"][seq] = { + 'followAction': 1, + 'followParam': 0, + 'group': seq, + 'tracks': [{'chan': seq}]} + + + except Exception as e: + logging.warning(e) + def version_2(self): # Convert snapshot from schema V2 to V3 @@ -135,8 +294,7 @@ def version_0(self): } try: - state["zs3"]["zs3-0"]["active_chain"] = int( - f"{self.snapshot['index']:02d}") + 1 + state["zs3"]["zs3-0"]["active_chain"] = int(f"{self.snapshot['index']:02d}") + 1 except: pass @@ -199,7 +357,6 @@ def version_0(self): "audio_processors": [], # Temporary list of processors in chain - used to build slots "mixer_chan": midi_chan, "midi_chan": None, - "current_processor": 0, "slots": [] } chain_state = { @@ -246,8 +403,7 @@ def version_0(self): try: for input in self.snapshot["audio_capture"][jackname]: if input.startswith("system:capture_"): - state["zs3"]["zs3-0"]["chains"][chain_id]["audio_in"].append( - int(input.split("_")[1])) + state["zs3"]["zs3-0"]["chains"][chain_id]["audio_in"].append(int(input.split("_")[1])) except: pass @@ -412,16 +568,13 @@ def version_0(self): # Fix-up audio outputs if chain_id == 0: - state["zs3"]["zs3-0"]["chains"][chain_id]["audio_out"].append( - "system:playback_[1,2]$") + state["zs3"]["zs3-0"]["chains"][chain_id]["audio_out"].append("system:playback_[1,2]$") else: for out in audio_out: if out == "mixer": - state["zs3"]["zs3-0"]["chains"][chain_id]["audio_out"].append( - 0) + state["zs3"]["zs3-0"]["chains"][chain_id]["audio_out"].append(0) elif isinstance(out, int): - state["zs3"]["zs3-0"]["chains"][chain_id]["audio_out"].append( - out) + state["zs3"]["zs3-0"]["chains"][chain_id]["audio_out"].append(out) state["zs3"]["zs3-0"]["chains"][chain_id]["midi_out"] = midi_out fixed_slots = [] diff --git a/zyngine/zynthian_lv2.py b/zyngine/zynthian_lv2.py index cd0fcd7b8..920ff14f9 100755 --- a/zyngine/zynthian_lv2.py +++ b/zyngine/zynthian_lv2.py @@ -5,7 +5,7 @@ # # zynthian LV2 # -# Copyright (C) 2015-2024 Fernando Moyano +# Copyright (C) 2015-2025 Fernando Moyano # # ****************************************************************************** # @@ -48,6 +48,7 @@ class EngineType(Enum): AUDIO_EFFECT = "Audio Effect" AUDIO_GENERATOR = "Audio Generator" SPECIAL = "Special" + GLOBAL = "Global" # UNKNOWN = "Unknown" @@ -156,6 +157,7 @@ class EngineType(Enum): "FS": ["FluidSynth", "FluidSynth: SF2, SF3", "MIDI Synth", "Sampler", True], "SF": ["Sfizz", "Sfizz: SFZ", "MIDI Synth", "Sampler", True], "LS": ["LinuxSampler", "LinuxSampler: SFZ, GIG", "MIDI Synth", "Sampler", True], + "CL": ["Clippy", "Clip launcher", "Audio Generator", "Other", True], "BF": ["setBfree", "setBfree - Hammond Emulator", "MIDI Synth", "Organ", True], "AE": ["Aeolus", "Aeolus - Pipe Organ Emulator", "MIDI Synth", "Organ", True], "PT": ['Pianoteq', "Pianoteq", "MIDI Synth", "Piano", True], @@ -499,6 +501,11 @@ def get_engines_by_type(): for key, info in engines.items(): engines_by_type[info['TYPE']][key] = info + try: + del engines_by_type["Global"] + except: + pass + return engines_by_type # ------------------------------------------------------------------------------ @@ -686,8 +693,10 @@ def _generate_plugin_presets_cache(plugin): 'presets': [] } - if label.startswith(bank_label): - label = label[len(bank_label) + 1:].strip() + # Disabled because it's ugly and doesn't work very well + #if label.startswith(bank_label): + # label = label[len(bank_label) + 1:].strip() + presets_info[bank_label]['presets'].append({ 'label': str(label), 'url': str(preset) @@ -705,8 +714,7 @@ def _generate_plugin_presets_cache(plugin): if len(presets_info[k]['presets']) == 0: del (presets_info[k]) else: - presets_info[k]['presets'] = sorted( - presets_info[k]['presets'], key=lambda k: k['label']) + presets_info[k]['presets'] = sorted(presets_info[k]['presets'], key=lambda k: k['label']) # Save cache file save_plugin_presets_cache(plugin_name, presets_info) @@ -797,11 +805,18 @@ def get_plugin_ports(plugin_url): is_enumeration = control.has_property(world.ns.lv2.enumeration) is_logarithmic = control.has_property(world.ns.portprops.logarithmic) + # Parameter Desgination => http://lv2plug.in/ns/lv2core#designation + designation = str(control.get(world.ns.lv2.designation)) + # Detect Envelope designation envelope = None - for env_type in ["delay", "attack", "hold", "decay", "sustain", "fade", "release"]: - # "http://lv2plug.in/ns/lv2core#designation" - if str(control.get(world.ns.lv2.designation)) == f"http://lv2plug.in/ns/ext/parameters#{env_type}": - envelope = env_type + for env_param in ["delay", "attack", "hold", "decay", "sustain", "fade", "release"]: + if designation == f"http://lv2plug.in/ns/ext/parameters#{env_param}": + envelope = env_param + # Detect Filter designation + filter = None + for flt_param in ["cutoffFrequency", "resonance"]: + if designation == f"http://lv2plug.in/ns/ext/parameters#{flt_param}": + filter = flt_param not_on_gui = control.has_property(world.ns.portprops.notOnGUI) display_priority = control.get(world.ns.lv2.displayPriority) @@ -868,6 +883,13 @@ def get_plugin_ports(plugin_url): except: vdef = vmin + if symbol == "BYPASS" and plugin_url.startswith("http://guitarix"): + # Invert bypass for guitarix effects + is_toggled = True + vmin = 1 + vmax = 0 + vdef = 0 + ports_info[i] = { 'index': i, 'symbol': symbol, @@ -891,6 +913,7 @@ def get_plugin_ports(plugin_url): 'path_file_types': None, 'path_preload': False, 'envelope': envelope, + 'filter': filter, 'not_on_gui': not_on_gui, 'display_priority': display_priority, 'scale_points': sp @@ -898,6 +921,7 @@ def get_plugin_ports(plugin_url): #logging.debug("CONTROL PORT {} => {}".format(i, ports_info[i])) # Property parameters + i = len(ports_info) for control in world.find_nodes(plugin.get_uri(), world.ns.patch.writable, None): symbol = world.get_symbol(control) name = str(world.get(control, world.ns.rdfs.label, None)) @@ -921,12 +945,14 @@ def get_plugin_ports(plugin_url): # TODO => Implement LV2 port propierty for path preload => only if really needed! path_preload = True envelope = None + filter = None sp = [] else: vdef = get_node_value(world.get(control, world.ns.lv2.default, None)) vmin = get_node_value(world.get(control, world.ns.lv2.minimum, None)) vmax = get_node_value(world.get(control, world.ns.lv2.maximum, None)) + is_trigger = False is_toggled = (range_type == world.ns.atom.Bool) is_integer = (range_type == world.ns.atom.Int) is_enumeration = world.get(control, world.ns.lv2.enumeration, None) is not None @@ -935,10 +961,18 @@ def get_plugin_ports(plugin_url): path_file_types = None path_preload = False + # Parameter Desgination => http://lv2plug.in/ns/lv2core#designation + designation = str(world.get(control, world.ns.lv2.designation, None)) + # Detect Envelope designation envelope = None - for env_type in ["delay", "attack", "hold", "decay", "sustain", "fade", "release"]: - if str(world.get(control, world.ns.lv2.designation, None)) == f"http://lv2plug.in/ns/ext/parameters#{env_type}": - envelope = env_type + for env_param in ["delay", "attack", "hold", "decay", "sustain", "fade", "release"]: + if designation == f"http://lv2plug.in/ns/ext/parameters#{env_param}": + envelope = env_param + # Detect Filter designation + filter = None + for flt_param in ["cutoffFrequency", "resonance"]: + if designation == f"http://lv2plug.in/ns/ext/parameters#{flt_param}": + filter = flt_param sp = [] for p in world.find_nodes(control, world.ns.lv2.scalePoint, None): @@ -1012,6 +1046,7 @@ def get_plugin_ports(plugin_url): 'path_file_types': path_file_types, 'path_preload': path_preload, 'envelope': envelope, + 'filter': filter, 'not_on_gui': not_on_gui, 'display_priority': display_priority, 'scale_points': sp diff --git a/zyngine/zynthian_processor.py b/zyngine/zynthian_processor.py index e10bfb9bf..9ee043111 100644 --- a/zyngine/zynthian_processor.py +++ b/zyngine/zynthian_processor.py @@ -4,7 +4,7 @@ # # zynthian processor # -# Copyright (C) 2015-2023 Fernando Moyano +# Copyright (C) 2015-2024 Fernando Moyano # Brian Walton # # ***************************************************************************** @@ -24,13 +24,14 @@ # ***************************************************************************** import os +import re import copy import logging import traceback # Zynthian specific modules from zyncoder.zyncore import lib_zyncore - +from zyngine import zynthian_controller class zynthian_processor: @@ -114,7 +115,8 @@ def set_engine(self, engine): """Set engine that this processor uses""" self.engine = engine - self.engine.add_processor(self) + if engine: + self.engine.add_processor(self) def get_name(self): """Get name of processor""" @@ -142,6 +144,10 @@ def get_chain_id(self): return self.chain_id + def reset(self): + for zctrl in self.controllers_dict.values(): + zctrl.reset_value() + # --------------------------------------------------------------------------- # MIDI autolearn CC controllers # --------------------------------------------------------------------------- @@ -611,7 +617,10 @@ def get_ctrl_screen(self, key): try: return self.ctrl_screens_dict[key] except: - return None + keys = list(self.ctrl_screens_dict) + if keys: + return self.ctrl_screens_dict[keys[0]] + return [] def get_current_screen_index(self): """Get index of last selected controller screen @@ -698,6 +707,49 @@ def get_group_zctrls(self, group): zctrls.append(zctrl) return zctrls + # --------------------------------------------------------------------------- + # Keymap management + # --------------------------------------------------------------------------- + + # Returns a keymap name if the keymap can be generated + def get_keymap_name(self): + if self.engine.name.startswith("Jalv/"): + logging.debug(f"KEYMAP PLUGIN NAME => {self.engine.plugin_name}") + if self.engine.plugin_name == "Fabla": + return "Fabla - " + self.preset_name + return None + + # Returns keymap if possible + def get_keymap(self): + if self.engine.name.startswith("Jalv/"): + if self.engine.plugin_name == "Fabla": + return self.get_keymap_fabla() + return None + + def get_keymap_fabla(self): + keymap = [] + base_note = int(self.controllers_dict[f"base_note"].get_value()) + for i in range(64): + try: + fpath = self.controllers_dict[f"pad_fpath_{i + 1}"].get_value() + except: + continue + try: + name = os.path.splitext(os.path.basename(fpath))[0].strip() + if name: + name = re.sub("^\d*_*", '', name) + keymap.append({ + "note": base_note + i, + "name": name, + "colour": "white" + }) + except Exception as e: + logging.error(f"Can't add keymap element {i}, {fpath} => {e}") + return keymap + + # QUESTION: Should we move here the code for generating keymaps (midnam files & scales) from pattern editor? + # It makes sense ... + # ---------------------------------------------------------------------------- # MIDI processing # ---------------------------------------------------------------------------- @@ -766,6 +818,7 @@ def set_state(self, state): """Configure processor from state model dictionary state : Processor state + returns : list of cc learn config: [chain, chan, cc, zctrl] """ if "bank_subdir_info" in state and state["bank_subdir_info"]: @@ -816,12 +869,29 @@ def set_state(self, state): for symbol, ctrl_state in state["controllers"].items(): try: zctrl = self.controllers_dict[symbol] + reconfig = False if "value" in ctrl_state: zctrl.set_value(ctrl_state["value"], True) if "midi_cc_momentary_switch" in ctrl_state: zctrl.midi_cc_momentary_switch = ctrl_state['midi_cc_momentary_switch'] if "midi_cc_debounce" in ctrl_state: zctrl.midi_cc_debounce = ctrl_state['midi_cc_debounce'] + if "midi_cc_val1" in ctrl_state: + if zctrl.midi_cc_val1 != ctrl_state['midi_cc_val1']: + zctrl.midi_cc_val1 = ctrl_state['midi_cc_val1'] + reconfig = True + elif zctrl.midi_cc_val1 != zctrl.value_min: + zctrl.midi_cc_val1 = zctrl.value_min + reconfig = True + if "midi_cc_val2" in ctrl_state: + if zctrl.midi_cc_val2 != ctrl_state['midi_cc_val2']: + zctrl.midi_cc_val2 = ctrl_state['midi_cc_val2'] + reconfig = True + elif zctrl.midi_cc_val2 != zctrl.value_max: + zctrl.midi_cc_val2 = zctrl.value_max + reconfig = True + if reconfig: + zctrl._configure() except Exception as e: logging.warning(f"Invalid controller for processor {self.get_basepath()}: {e}") diff --git a/zyngine/zynthian_signal_manager.py b/zyngine/zynthian_signal_manager.py index 1a6fa09f8..721cf5228 100644 --- a/zyngine/zynthian_signal_manager.py +++ b/zyngine/zynthian_signal_manager.py @@ -42,20 +42,25 @@ class zynthian_signal_manager: S_AUDIO_PLAYER = 5 S_SMF_RECORDER = 6 S_ALSA_MIXER = 7 - S_AUDIO_MIXER = 8 + S_MIXER = 8 S_STEPSEQ = 9 S_CUIA = 10 S_GUI = 11 S_MIDI = 12 + S_TRANSPORT = 13 SS_CUIA_REFRESH = 0 SS_CUIA_MIDI_EVENT = 1 + #TODO: These are duplicates of definitions within zyngui!!! SS_GUI_SHOW_SCREEN = 0 SS_GUI_SHOW_SIDEBAR = 1 SS_GUI_CONTROL_MODE = 2 SS_GUI_SHOW_FILE_SELECTOR = 3 - SS_GUI_SHOW_MESSAGE = 4 + SS_GUI_TOGGLE_ALT_MODE = 4 + SS_GUI_SHOW_MESSAGE = 5 + SS_GUI_LAUNCHER_MODE = 6 + SS_GUI_VIEW_POS = 7 SS_MIDI_ALL = 0 SS_MIDI_CC = 1 @@ -118,8 +123,8 @@ def unregister(self, signal, subsignal, callback): del self.signal_register[signal][subsignal][k] n += 1 if n == 0: - logging.warning( - f"Callback not registered for signal({signal},{subsignal})") + #logging.warning(f"Callback not registered for signal({signal},{subsignal})") + pass def unregister_all(self, callback): n = 0 diff --git a/zyngine/zynthian_state_manager.py b/zyngine/zynthian_state_manager.py index 98b91cea6..bb0003d60 100644 --- a/zyngine/zynthian_state_manager.py +++ b/zyngine/zynthian_state_manager.py @@ -4,7 +4,7 @@ # # zynthian state manager # -# Copyright (C) 2015-2024 Fernando Moyano +# Copyright (C) 2015-2025 Fernando Moyano # Brian Walton # # **************************************************************************** @@ -23,7 +23,6 @@ # # **************************************************************************** -import base64 import ctypes import logging import traceback @@ -46,13 +45,12 @@ # Python wrapper for zynsmf (ensures initialised and wraps load() function) from zynlibs.zynsmf import zynsmf from zynlibs.zynsmf.zynsmf import libsmf # Direct access to shared library +from zynlibs.zynmixer import zynmixer from zyngine.zynthian_chain_manager import * -from zyngine.zynthian_processor import zynthian_processor from zyngine.zynthian_audio_recorder import zynthian_audio_recorder from zyngine.zynthian_signal_manager import zynsigman from zyngine.zynthian_legacy_snapshot import zynthian_legacy_snapshot, SNAPSHOT_SCHEMA_VERSION -from zyngine import zynthian_engine_audio_mixer from zyngine import zynthian_midi_filter from zyngui import zynthian_gui_config @@ -69,6 +67,10 @@ ex_data_dir = os.environ.get('ZYNTHIAN_EX_DATA_DIR', "/media/root") capture_dir_sdc = my_data_dir + "/capture" +MAIN_MIXBUS_ID = -1 +ALSA_ID = -2 +AUDIO_PLAYER_ID = -3 +TEMPO_ID = -4 class zynthian_state_manager: @@ -120,7 +122,7 @@ def __init__(self): self.last_event_ts = monotonic() # Status - self.status_xrun = False + self.status_xrun = 0 self.status_undervoltage = False self.overtemp_warning = 75 # Temperature limit before warning overtemperature self.status_overtemp = False @@ -130,12 +132,13 @@ def __init__(self): self.status_midi_player = False self.last_midi_file = None self.status_midi = False + self.status_midi_ch = 0 # 16-bit MIDI activity indicator, 1-bit per MIDI self.status_midi_clock = False self.update_available = False # True when updates available from repositories self.checking_for_updates = False # True whilst checking for updates self.midi_filter_script = None - self.midi_learn_state = False + self.midi_learn_state = False # False for disabled, None for learning global, chain id for learning chain # When ZS3 Program Change MIDI learning is enabled, the name used for creating new ZS3, empty string for auto-generating a name. None when disabled. self.midi_learn_pc = None self.midi_learn_zctrl = None # zctrl currently being learned @@ -149,19 +152,16 @@ def __init__(self): self.hwmon_thermal_file = None self.hwmon_undervolt_file = None - self.zynmixer = zynthian_engine_audio_mixer.zynmixer() + self.zynmixer_chan = zynmixer.ZynMixer() # zynmixer used for channel strips + self.zynmixer_bus = zynmixer.ZynMixer(True) # zynmixer used for buses, e.g main, fx return, etc. self.chain_manager = zynthian_chain_manager(self) self.reset_zs3() - self.alsa_mixer_processor = zynthian_processor("MX", { - "NAME": "Mixer", "TITLE": "ALSA Mixer", "TYPE": "MIXER", - "CAT": None, "ENGINE": zynthian_engine_alsa_mixer, "ENABLED": True - }) - self.alsa_mixer_processor.engine = zynthian_engine_alsa_mixer(self, self.alsa_mixer_processor) - self.alsa_mixer_processor.refresh_controllers() + self.zynseq = zynseq.zynseq(self) + self.alsa_mixer_processor = self.chain_manager.add_processor(None, "MX", None, ALSA_ID) + self.tempo_processor = self.chain_manager.add_processor(None, "TP", None, TEMPO_ID) #TODO: Use zynseq engine directly self.audio_recorder = zynthian_audio_recorder(self) - self.zynseq = zynseq.zynseq(self) self.ctrldev_manager = None self.audio_player = None self.aubio_in = [1, 2] # List of aubio inputs @@ -231,14 +231,13 @@ def start(self): # Start VNC as configured self.default_vncserver() + self.chain_manager.add_chain(0) self.ctrldev_manager = zynthian_ctrldev_manager(self) zynautoconnect.start(self) self.jack_period = self.get_jackd_blocksize() / self.get_jackd_samplerate() - self.zynmixer.reset_state() + self.main_mixbus_proc = self.chain_manager.add_processor(0, "MR", 0, MAIN_MIXBUS_ID) self.reload_midi_config() self.create_audio_player() - self.chain_manager.add_chain(0) - self.exit_flag = False self.slow_thread = Thread(target=self.slow_thread_task) self.slow_thread.name = "Status Manager Slow" @@ -274,7 +273,7 @@ def stop(self): zynautoconnect.pause() self.chain_manager.remove_all_chains(True) self.reset_zs3() - self.zynseq.load("") + self.zynseq.reset() self.ctrldev_manager.unload_all_drivers() self.destroy_audio_player() zynautoconnect.stop() @@ -307,21 +306,23 @@ def clean(self, chains=True, zynseq=True): sequences : True for cleaning zynseq state (sequences) """ - self.zynmixer.set_mute(self.zynmixer.MAX_NUM_CHANNELS - 1, 1) + self.mute() # self.zynseq.transport_stop("ALL") self.zynseq.libseq.stop() if zynseq: - self.zynseq.load("") + self.zynseq.reset() if chains: zynautoconnect.pause() self.chain_manager.remove_all_chains(True) self.reset_zs3() - self.zynmixer.reset_state() + self.zynmixer_chan.reset() + self.zynmixer_bus.reset() self.reload_midi_config() zynautoconnect.request_midi_connect(True) zynautoconnect.request_audio_connect(True) zynautoconnect.resume() - self.zynmixer.set_mute(self.zynmixer.MAX_NUM_CHANNELS - 1, 0) + self.chain_manager.chains[0] + self.mute(False) def clean_all(self): """Remove ALL Chains & Sequences.""" @@ -348,13 +349,14 @@ def clean_sequences(self): self.end_busy("clean sequences") self.busy.clear() # Sometimes it's needed, why?? + def mute(self, mute=True, wait=0.01): + self.main_mixbus_proc.controllers_dict["mute"].set_value(mute) + sleep(wait) + # ------------------------------------------------------------------------- # Internal parameters and core limits # ------------------------------------------------------------------------- - def get_max_num_mixer_chans(self): - return MAX_NUM_MIXER_CHANS - def get_num_zmop_chains(self): return NUM_ZMOP_CHAINS @@ -382,6 +384,12 @@ def get_zmip_int_index(self): def get_zmip_ctrl_index(self): return ZMIP_CTRL_INDEX + def get_zmop_mod_index(self): + return ZMOP_MOD_INDEX + + def get_zmop_step_index(self): + return ZMOP_STEP_INDEX + # ------------------------------------------------------------------------- # Busy state management # ------------------------------------------------------------------------- @@ -618,7 +626,6 @@ def slow_thread_task(self): self.status_midi_player = status_midi_player if status_midi_player == 0: self.zynseq.transport_stop("zynsmf") - zynsigman.send(zynsigman.S_STATE_MAN, self.SS_MIDI_PLAYER_STATE, state=status_midi_player) # MIDI Recorder @@ -633,13 +640,14 @@ def slow_thread_task(self): # Clean some status flags if xruns_status: - self.status_xrun = False - xruns_status = False + self.status_xrun = 0 + xruns_status = 0 if self.status_xrun: - xruns_status = True + xruns_status = self.status_xrun if midi_status: self.status_midi = False + self.status_midi_ch = 0 midi_status = False if self.status_midi: midi_status = True @@ -811,8 +819,6 @@ def zynmidi_read(self): else: if self.midi_learn_zctrl: self.chain_manager.add_midi_learn(chan, ccnum, self.midi_learn_zctrl, izmip) - else: - self.zynmixer.midi_control_change(chan, ccnum, ccval) # Master Note CUIA with ZynSwitch emulation elif evtype == 0x8 or evtype == 0x9: note = str(ev[1] & 0x7F) @@ -844,7 +850,6 @@ def zynmidi_read(self): if ccnum < 120: if not self.midi_learn_zctrl: self.chain_manager.midi_control_change(izmip, chan, ccnum, ccval) - self.zynmixer.midi_control_change(chan, ccnum, ccval) self.alsa_mixer_processor.midi_control_change(chan, ccnum, ccval) self.audio_player.midi_control_change(chan, ccnum, ccval) zynsigman.send_queued(zynsigman.S_MIDI, zynsigman.SS_MIDI_CC, @@ -882,8 +887,7 @@ def zynmidi_read(self): chan = self.chain_manager.get_active_chain().midi_chan send_signal = self.chain_manager.set_midi_prog_preset(chan, pgm) if send_signal: - zynsigman.send_queued(zynsigman.S_MIDI, zynsigman.SS_MIDI_PC, - izmip=izmip, chan=chan, num=pgm) + zynsigman.send_queued(zynsigman.S_MIDI, zynsigman.SS_MIDI_PC, izmip=izmip, chan=chan, num=pgm) # Note Off elif evtype == 0x8: @@ -901,6 +905,7 @@ def zynmidi_read(self): # Flag MIDI event self.status_midi = True + self.status_midi_ch |= (1 << chan) self.last_event_flag = True except Exception as err: @@ -950,6 +955,9 @@ def reset_event_flag(self): # Snapshot Save & Load # ---------------------------------------------------------------------------- + def get_schema(self): + return SNAPSHOT_SCHEMA_VERSION + def get_state(self): """Get a dictionary describing the full state model""" @@ -961,7 +969,10 @@ def get_state(self): 'midi_profile_state': self.get_midi_profile_state(), 'chains': self.chain_manager.get_state(), 'zs3': self.zs3, - 'last_zs3_id': self.last_zs3_id + 'last_zs3_id': self.last_zs3_id, + 'gui': { + 'pinned_chains': self.chain_manager.get_pinned_count() + } } engine_states = {} @@ -972,28 +983,15 @@ def get_state(self): if engine_states: state["engine_config"] = engine_states - # Add ALSA-Mixer setting - if zynthian_gui_config.snapshot_mixer_settings and self.alsa_mixer_processor: - state['alsa_mixer'] = self.alsa_mixer_processor.get_state() - - # Audio Recorder Armed - armed_state = [] - for midi_chan in range(self.zynmixer.MAX_NUM_CHANNELS): - if self.audio_recorder.is_armed(midi_chan): - armed_state.append(midi_chan) - if armed_state: - state['audio_recorder_armed'] = armed_state - - # Zynseq RIFF data - binary_riff_data = self.zynseq.get_riff_data() - b64_data = base64.b64encode(binary_riff_data) - state['zynseq_riff_b64'] = b64_data.decode('utf-8') + # zynseq json + self.zynseq.refresh_state() + state['zynseq'] = self.zynseq.state return state def export_chain(self, fpath, chain_id): """Save just a single chain to a snapshot file - + fpath: Full filename and path chain_id: Chain to export """ @@ -1026,7 +1024,7 @@ def export_chain(self, fpath, chain_id): except: pass - for key in ["last_snapshot_fpath", "midi_profile_state", "engine_config", "audio_recorder_armed", "zynseq_riff_b64", "alsa_mixer", "zyngui"]: + for key in ["last_snapshot_fpath", "midi_profile_state", "zynseq"]: try: del state[key] except: @@ -1107,22 +1105,17 @@ def load_snapshot(self, fpath, load_chains=True, load_sequences=True, merge=Fals self.end_busy("load snapshot") return None - mute = self.zynmixer.get_mute(self.zynmixer.MAX_NUM_CHANNELS - 1) + mute = self.zynmixer_bus.get_mute(0) try: snapshot = JSONDecoder().decode(json) self.set_busy_details("fixing legacy snapshot") converter = zynthian_legacy_snapshot(self) state = converter.convert_state(snapshot) - if load_sequences and "zynseq_riff_b64" in state: - b64_bytes = state["zynseq_riff_b64"].encode("utf-8") - binary_riff_data = base64.decodebytes(b64_bytes) - self.zynseq.restore_riff_data(binary_riff_data) - self.zynseq.update_tempo() - + # Load chains if load_chains: # Mute output to avoid unwanted noises - self.zynmixer.set_mute(self.zynmixer.MAX_NUM_CHANNELS - 1, True) + self.mute(True) zynautoconnect.pause() if "chains" in state: @@ -1133,7 +1126,7 @@ def load_snapshot(self, fpath, load_chains=True, load_sequences=True, merge=Fals if merge: # Remove elements that are not to be merged - for key in ["last_snapshot_fpath", "last_zs3_id", "midi_profile_state", "audio_recorder_armed", "zynseq_riff_b64", "alsa_mixer", "zyngui"]: + for key in ["last_snapshot_fpath", "last_zs3_id", "midi_profile_state", "zynseq"]: try: del state[key] except: @@ -1141,7 +1134,6 @@ def load_snapshot(self, fpath, load_chains=True, load_sequences=True, merge=Fals # Need to reassign chains and processor ids chain_map = {} # Map of new chain id indexed by old id proc_map = {} # Map of new processor id indexed by old id - mixer_map = {} # Map of new mixer chan idx indexed by old idx # Don't import main chain try: del state["chains"]["0"] @@ -1152,13 +1144,8 @@ def load_snapshot(self, fpath, load_chains=True, load_sequences=True, merge=Fals if new_proc_id <= id: new_proc_id = id + 1 - mixer_chan = 0 for chain_id, chain_state in state["chains"].items(): # Fix mixer channel - mixer_chan = self.chain_manager.get_next_free_mixer_chan(mixer_chan) - mixer_map[int(chain_state["mixer_chan"])] = mixer_chan - chain_state["mixer_chan"] = mixer_chan - mixer_chan += 1 new_chain_id = 1 while new_chain_id in self.chain_manager.chains: new_chain_id += 1 @@ -1188,18 +1175,6 @@ def load_snapshot(self, fpath, load_chains=True, load_sequences=True, merge=Fals if str(ctrl_cfg[0]) in proc_map: ctrl_cfg[0] = proc_map[str(ctrl_cfg[0])] state["zs3"]["zs3-0"]["chains"] = chains - mixer_chans = {} - for old_mixer_chan, new_mixer_chan in mixer_map.items(): - try: - mixer_chans[f"chan_{new_mixer_chan:02d}"] = state["zs3"]["zs3-0"]["mixer"][f"chan_{old_mixer_chan:02d}"] - except: - pass - state["zs3"]["zs3-0"]["mixer"] = mixer_chans - # We don't want to merge MIDI binding to mixer - try: - del state["zs3"]["zs3-0"]["mixer"]["midi_learn"] - except: - pass # We don't want to merge MIDI capture try: del state["zs3"]["zs3-0"]["midi_capture"] @@ -1219,20 +1194,10 @@ def load_snapshot(self, fpath, load_chains=True, load_sequences=True, merge=Fals self.zs3 = zs3 self.load_zs3(zs3["zs3-0"], autoconnect=False) try: - mute |= self.zs3["zs3-0"]["mixer"]["chan_16"]["mute"] + mute |= state['zs3']['zs3-0']['processors'][MAIN_MIXBUS_ID]['controllers']['mute']["value"] except: pass - if "alsa_mixer" in state: - self.alsa_mixer_processor.set_state(state["alsa_mixer"]) - - if "audio_recorder_armed" in state: - for midi_chan in range(self.zynmixer.MAX_NUM_CHANNELS): - if midi_chan in state["audio_recorder_armed"]: - self.audio_recorder.arm(midi_chan) - else: - self.audio_recorder.unarm(midi_chan) - if "midi_profile_state" in state: self.set_midi_profile_state(state["midi_profile_state"]) @@ -1240,6 +1205,17 @@ def load_snapshot(self, fpath, load_chains=True, load_sequences=True, merge=Fals for proc in self.chain_manager.processors.values(): proc.set_midi_autolearn(True) + # GUI + if "gui" in state: + self.chain_manager.set_pinned(state["gui"].get("pinned_chains", 1)) + + + # Load Sequences after loading chains + if load_sequences and "zynseq" in state: + if not self.zynseq.set_state(state["zynseq"]): + self.set_busy_warning("Invalid sequence data within snapshot") + sleep(2) + # Save last snapshot info and get snapshot's program number self.last_snapshot_count += 1 if basename(fpath) != "last_state.zss": @@ -1264,7 +1240,7 @@ def load_snapshot(self, fpath, load_chains=True, load_sequences=True, merge=Fals zynautoconnect.request_audio_connect(True) # Restore mute state - self.zynmixer.set_mute(self.zynmixer.MAX_NUM_CHANNELS - 1, mute) + self.mute(mute, 0) # Signal snapshot loading zynsigman.send_queued(zynsigman.S_STATE_MAN, self.SS_LOAD_SNAPSHOT) @@ -1366,12 +1342,12 @@ def get_zs3_title(self, zs3_id=None): def set_zs3_title(self, zs3_id, title): self.zs3[zs3_id]["title"] = title - def toggle_zs3_chain_restore_flag(self, zs3_id, chain_id): + def toggle_zs3_restore_flag(self, zs3_id, type, id): zs3_state = self.zs3[zs3_id] - if chain_id == "mixer": - tstate = zs3_state["mixer"] - else: - tstate = zs3_state["chains"][chain_id] + try: + tstate = zs3_state[type][int(id)] + except: + return try: tstate["restore"] = not tstate["restore"] except: @@ -1384,6 +1360,7 @@ def load_zs3(self, zs3_id, autoconnect=True): Returns : True on success """ + active_phrase = 0 if isinstance(zs3_id, str): # Try loading exact match try: @@ -1412,29 +1389,18 @@ def load_zs3(self, zs3_id, autoconnect=True): self.set_busy_details("restoring chains state") for chain_id, chain_state in zs3_state["chains"].items(): chain_id = int(chain_id) - try: restore_flag = chain_state["restore"] except: restore_flag = True - if not restore_flag: continue - chain = self.chain_manager.get_chain(chain_id) if chain: restored_chains.append(chain_id) else: continue - try: - if zs3_state["mixer"][f"chan_{chain.mixer_chan:02}"]["mute"]: - # Avoid subsequent config changes from being heard on muted chains - self.zynmixer.set_mute(chain.mixer_chan, 1) - mute_pause = True - except: - pass - if "midi_chan" in chain_state: if chain.midi_chan is not None and chain.midi_chan != chain_state['midi_chan']: self.chain_manager.set_midi_chan(chain_id, chain_state['midi_chan']) @@ -1456,12 +1422,11 @@ def load_zs3(self, zs3_id, autoconnect=True): lib_zyncore.zmop_set_transpose_semitone(chain.zmop_index, chain_state["transpose_semitone"]) else: lib_zyncore.zmop_set_transpose_semitone(chain.zmop_index, 0) + if "midi_in" in chain_state: chain.midi_in = chain_state["midi_in"] if "midi_out" in chain_state: chain.midi_out = chain_state["midi_out"] - if "midi_thru" in chain_state: - chain.midi_thru = chain_state["midi_thru"] if "audio_in" in chain_state: chain.audio_in = chain_state["audio_in"] chain.audio_out = [] @@ -1474,9 +1439,6 @@ def load_zs3(self, zs3_id, autoconnect=True): chain.audio_out.append("^system:playback_1$|^system:playback_2$") elif out not in chain.audio_out: chain.audio_out.append(out) - - if "audio_thru" in chain_state: - chain.audio_thru = chain_state["audio_thru"] chain.rebuild_graph() # Current (right) chain MIDI-learn state @@ -1504,8 +1466,15 @@ def load_zs3(self, zs3_id, autoconnect=True): if "processors" in zs3_state: for proc_id, proc_state in zs3_state["processors"].items(): try: - processor = self.chain_manager.processors[int(proc_id)] - if processor.chain_id in restored_chains: + restore_flag = proc_state["restore"] + except: + restore_flag = True + if not restore_flag: + continue + try: + id = int(proc_id) + processor = self.chain_manager.processors[id] + if id < 0 or processor.chain_id in restored_chains: self.set_busy_details(f"restoring {processor.get_basepath()} state") processor.set_state(proc_state) except Exception as e: @@ -1521,23 +1490,30 @@ def load_zs3(self, zs3_id, autoconnect=True): if "active_chain" in zs3_state: self.chain_manager.set_active_chain_by_id(zs3_state["active_chain"]) - - if "mixer" in zs3_state: - try: - restore_flag = zs3_state["mixer"]["restore"] - except: - restore_flag = True - if restore_flag: - self.set_busy_details("restoring mixer state") - self.zynmixer.set_state(zs3_state["mixer"]) + if "active_phrase" in zs3_state: + active_phrase = zs3_state["active_phrase"] if "midi_capture" in zs3_state: self.set_busy_details("restoring midi capture state") self.set_midi_capture_state(zs3_state['midi_capture']) if "global" in zs3_state: + try: + zynautoconnect.set_ext_clock_device_name(zs3_state["global"]["clock_source"]) + except: + zynautoconnect.set_ext_clock_device_name(None) + try: + zynautoconnect.set_midi_clock_output_ports(zs3_state["global"]["clock_outputs"]) + except: + zynautoconnect.set_midi_clock_output_ports([]) + try: + zynautoconnect.set_zynseq_exclude_ports(zs3_state["global"]["zynseq_excluded_inputs"]) + except: + zynautoconnect.set_zynseq_exclude_ports([]) + if "midi_transpose" in zs3_state["global"]: lib_zyncore.set_global_transpose(int(zs3_state["global"]["midi_transpose"])) + if "zctrl_x" in zs3_state["global"]: try: processor = self.chain_manager.processors[zs3_state["global"]["zctrl_x"][0]] @@ -1563,6 +1539,7 @@ def load_zs3(self, zs3_id, autoconnect=True): if zs3_id != 'zs3-0': self.last_zs3_id = zs3_id #self.zs3['zs3-0'] = self.zs3[zs3_id].copy() + self.zynseq.select_phrase(active_phrase, True) zynsigman.send(zynsigman.S_STATE_MAN, self.SS_LOAD_ZS3, zs3_id=zs3_id) if autoconnect: @@ -1580,7 +1557,7 @@ def get_next_zs3_index(self): pass elif zid.startswith("zs3-"): try: - used_ids.append(int(zid.split('-')[1])) + used_indexes.append(int(zid.split('-')[1])) except: pass @@ -1613,18 +1590,33 @@ def save_zs3(self, zs3_id=None, title=None): else: title = f"ZS3-{index}" + # Store persistent config + omit_processors = [] + omit_chains = [] + if zs3_id in self.zs3: + zs3 = self.zs3[zs3_id] + if "processors" in zs3: + for proc_id, proc in zs3["processors"].items(): + if "restore" in proc and not proc["restore"]: + omit_processors.append(proc_id) + if "chains" in zs3: + for chain_id, chain in zs3["chains"].items(): + if "restore" in chain and not chain["restore"]: + omit_chains.append(chain_id) + # Initialise zs3 self.zs3[zs3_id] = { "title": title, - "active_chain": self.chain_manager.active_chain_id, + "active_chain": self.chain_manager.active_chain.chain_id, "global": {} } chain_states = {} for chain_id, chain in self.chain_manager.chains.items(): chain_state = { - "midi_chan": chain.midi_chan, "midi_learn": {} } + if chain_id in omit_chains: + chain_state["restore"] = False if chain.is_midi(): note_low = lib_zyncore.zmop_get_note_low(chain.zmop_index) if note_low > 0: @@ -1638,12 +1630,11 @@ def save_zs3(self, zs3_id=None, title=None): transpose_semitone = lib_zyncore.zmop_get_transpose_semitone(chain.zmop_index) if transpose_semitone: chain_state["transpose_semitone"] = transpose_semitone + chain_state["midi_chan"] = chain.midi_chan if chain.midi_in: chain_state["midi_in"] = chain.midi_in.copy() if chain.midi_out: chain_state["midi_out"] = chain.midi_out.copy() - if chain.midi_thru: - chain_state["midi_thru"] = chain.midi_thru chain_state["audio_in"] = chain.audio_in.copy() chain_state["audio_out"] = [] for out in chain.audio_out: @@ -1654,8 +1645,6 @@ def save_zs3(self, zs3_id=None, title=None): out = [i, port_name] break chain_state["audio_out"].append(out) - if chain.audio_thru: - chain_state["audio_thru"] = chain.audio_thru # Add chain MIDI mapping for key, zctrls in self.chain_manager.chain_midi_cc_binding.items(): if chain_id == (key >> 16) & 0xff: @@ -1678,6 +1667,9 @@ def save_zs3(self, zs3_id=None, title=None): "preset_subdir_info": processor.preset_subdir_info, "controllers": {} } + if id in omit_processors: + processor_state["restore"] = False + # Add controllers for symbol, zctrl in processor.controllers_dict.items(): processor_state["controllers"][symbol] = zctrl.get_state() @@ -1685,17 +1677,15 @@ def save_zs3(self, zs3_id=None, title=None): if processor_states: self.zs3[zs3_id]["processors"] = processor_states - # Add mixer state - mixer_state = self.zynmixer.get_state(False) - if mixer_state: - self.zs3[zs3_id]["mixer"] = mixer_state - # Add MIDI capture state mcstate = self.get_midi_capture_state() if mcstate: self.zs3[zs3_id]["midi_capture"] = mcstate # Add global parameters + self.zs3[zs3_id]["global"]["clock_source"] = zynautoconnect.get_ext_clock_device_name() + self.zs3[zs3_id]["global"]["clock_outputs"] = zynautoconnect.get_midi_clock_output_ports() + self.zs3[zs3_id]["global"]["zynseq_excluded_inputs"] = zynautoconnect.get_zynseq_exclude_ports() self.zs3[zs3_id]["global"]["midi_transpose"] = lib_zyncore.get_global_transpose() try: processor_id = self.zctrl_x.processor.id @@ -1788,7 +1778,7 @@ def purge_zs3(self): for key, state in self.zs3.items(): if state["active_chain"] not in self.chain_manager.chains: - state["active_chain"] = self.chain_manager.active_chain_id + state["active_chain"] = self.chain_manager.active_chain.chain_id if "processors" in state: for processor_id in list(state["processors"]): if int(processor_id) not in self.chain_manager.processors: @@ -1805,13 +1795,21 @@ def purge_zs3(self): def get_last_zs3_index(self): return list(self.zs3.keys()).index(self.last_zs3_id) - def load_zs3_by_index(self, index): + def get_zs3_id_by_index(self, index): try: - zs3_id = list(self.zs3.keys())[index] + return list(self.zs3.keys())[index] except: logging.warning(f"Can't find ZS3 with index {index}") - return - return self.load_zs3(zs3_id) + + def save_zs3_by_index(self, index): + zs3_id = get_zs3_id_by_index(index) + if zs3_id: + return self.save_zs3(zs3_id) + + def load_zs3_by_index(self, index): + zs3_id = get_zs3_id_by_index(index) + if zs3_id: + return self.load_zs3(zs3_id) def load_next_zs3(self): try: @@ -1849,6 +1847,7 @@ def all_sounds_off(self): logging.info("All Sounds Off!") for chan in range(16): lib_zyncore.ui_send_ccontrol_change(chan, 120, 0) + self.zynseq.transport_stop("ALL") def all_notes_off(self): logging.info("All Notes Off!") @@ -1928,8 +1927,8 @@ def get_midi_capture_state(self): routed_chains.append(ch) mcstate[uid] = { "zmip_input_mode": bool(lib_zyncore.zmip_get_flag_active_chain(izmip)), - "zmip_system": bool(lib_zyncore.zmip_get_flag_system(izmip)), - "zmip_system_rt": bool(lib_zyncore.zmip_get_flag_system_rt(izmip)), + #"zmip_system": bool(lib_zyncore.zmip_get_flag_system(izmip)), + #"zmip_system_rt": bool(lib_zyncore.zmip_get_flag_system_rt(izmip)), "disable_ctrldev": self.ctrldev_manager.get_disabled_driver(uid), "ctrldev_driver": self.ctrldev_manager.get_driver_class_name(izmip), "routed_chains": routed_chains, @@ -1967,6 +1966,7 @@ def set_midi_capture_state(self, mcstate=None): lib_zyncore.zmip_set_flag_active_chain(izmip, bool(state["zmip_input_mode"])) except: pass + """ try: lib_zyncore.zmip_set_flag_system(izmip, bool(state["zmip_system"])) except: @@ -1975,6 +1975,7 @@ def set_midi_capture_state(self, mcstate=None): lib_zyncore.zmip_set_flag_system_rt(izmip, bool(state["zmip_system_rt"])) except: pass + """ try: self.aubio_in = state["audio_in"] except: @@ -2090,12 +2091,19 @@ def init_midi(self): # Set MIDI Master Channel lib_zyncore.set_midi_master_chan(zynthian_gui_config.master_midi_channel) # Setup MIDI filter rules - if self.midi_filter_script: - self.midi_filter_script.clean() - self.midi_filter_script = zynthian_midi_filter.MidiFilterScript(zynthian_gui_config.midi_filter_rules) + self.init_midi_filter() except Exception as e: logging.error(f"ERROR initializing MIDI : {e}") + def init_midi_filter(self): + """Reload MIDI Filter Rules""" + if self.midi_filter_script: + self.midi_filter_script.clean() + midi_filter_rules = zynthian_gui_config.midi_filter_rules + if zynthian_gui_config.midi_chanpress_cc > 0: + midi_filter_rules += f"\nMAP CP => CC#{zynthian_gui_config.midi_chanpress_cc}\n" + self.midi_filter_script = zynthian_midi_filter.MidiFilterScript(midi_filter_rules) + def reload_midi_config(self): """Reload MIDI configuration from saved state""" @@ -2118,57 +2126,6 @@ def init_midi_services(self): self.default_bluetooth() self.default_aubionotes() - # ------------------------------------------------------------------- - # MIDI transport & clock settings - # ------------------------------------------------------------------- - - def get_transport_clock_source(self): - val = self.zynseq.libseq.getClockSource() - if val == 5: - return 3 - elif val == 2: - return 2 - elif self.zynseq.libseq.getMidiClockOutput(): - return 1 - else: - return 0 - - def set_transport_clock_source(self, val=None, save_config=False): - if val is None: - val = zynthian_gui_config.transport_clock_source - - if val == 2: - self.zynseq.libseq.setClockSource(2) - elif val == 3: - self.zynseq.libseq.setClockSource(1 | 4) - else: - self.zynseq.libseq.setClockSource(1) - - self.zynseq.libseq.setMidiClockOutput(val == 1) - - # Save config - if save_config: - zynthian_gui_config.transport_clock_source = val - zynconf.update_midi_profile({ - "ZYNTHIAN_MIDI_TRANSPORT_CLOCK_SOURCE": str(int(val)) - }) - - def get_transport_analog_clock_divisor(self): - return self.zynseq.libseq.getAnalogClocksBeat() - - def set_transport_analog_clock_divisor(self, val=None, save_config=False): - if val is None: - val = zynthian_gui_config.transport_clock_source - - self.zynseq.libseq.setAnalogClocksBeat(val) - - # Save config - if save_config: - zynthian_gui_config.transport_analog_clock_divisor = val - zynconf.update_midi_profile({ - "ZYNTHIAN_MIDI_TRANSPORT_ANALOG_CLOCK_DIVISOR": str(int(val)) - }) - # ------------------------------------------------------------------- # MIDI profile # ------------------------------------------------------------------- @@ -2199,7 +2156,6 @@ def set_midi_profile_state(self, state): zynthian_gui_config.set_midi_config() self.init_midi() self.init_midi_services() - self.set_transport_clock_source() zynautoconnect.request_midi_connect() return True @@ -2214,16 +2170,11 @@ def reset_midi_profile(self): def create_audio_player(self): if not self.audio_player: - try: - self.audio_player = zynthian_processor("AP", self.chain_manager.engine_info["AP"]) - self.chain_manager.start_engine(self.audio_player, "AP") - except Exception as e: - logging.error( - f"Can't create global Audio Player instance => {e}\n{traceback.format_exc()}") + self.audio_player = self.chain_manager.add_processor(None, "AP", None, AUDIO_PLAYER_ID) def destroy_audio_player(self): if self.audio_player: - self.audio_player.engine.remove_processor(self.audio_player) + self.chain_manager.remove_processor(None, self.audio_player) self.audio_player = None self.status_audio_player = False @@ -2305,7 +2256,6 @@ def toggle_midi_record(self): def set_tempo(self, tempo): self.zynseq.set_tempo(tempo) - zynaudioplayer.set_tempo(tempo) def start_midi_playback(self, fpath): self.stop_midi_playback() @@ -2338,7 +2288,6 @@ def start_midi_playback(self, fpath): zynsigman.send(zynsigman.S_STATE_MAN, self.SS_MIDI_PLAYER_STATE, state=True) self.status_midi_player = False self.last_midi_file = fpath - # self.zynseq.libseq.transportLocate(0) except Exception as e: logging.error(f"ERROR STARTING MIDI PLAY: {e}") return False @@ -2347,6 +2296,7 @@ def start_midi_playback(self, fpath): def stop_midi_playback(self): if libsmf.getPlayState() != zynsmf.PLAY_STATE_STOPPED: libsmf.stopPlayback() + self.zynseq.transport_stop("zynsmf") self.status_midi_player = False zynsigman.send(zynsigman.S_STATE_MAN, self.SS_MIDI_PLAYER_STATE, state=False) return self.status_midi_player @@ -2379,7 +2329,7 @@ def start_vncserver(self, save_config=True): # Start VNC for Engine's native GUIs if not zynconf.is_service_active("vncserver1"): # Save state and stop engines - if self.chain_manager.get_chain_count() > 0: + if self.chain_manager.get_chain_count() > 1: self.save_last_state_snapshot() restore_state = True else: diff --git a/zyngui/__init__.py b/zyngui/__init__.py index 1fcd2cbf8..9dceab2a3 100644 --- a/zyngui/__init__.py +++ b/zyngui/__init__.py @@ -11,7 +11,6 @@ "zynthian_gui_admin", "zynthian_gui_snapshot", "zynthian_gui_processor_options", - "zynthian_gui_subprocessor_options", "zynthian_gui_engine", "zynthian_gui_midi_chan", "zynthian_gui_midi_cc", @@ -29,7 +28,6 @@ "zynthian_gui_chain_menu", "zynthian_gui_midi_recorder", "zynthian_gui_keyboard", - "zynthian_gui_tempo", "zynthian_gui_control_test", "zynthian_gui_splash", "zynthian_gui_loading", diff --git a/zyngui/multitouch.py b/zyngui/multitouch.py index 9b69b44c3..eb2e28eb1 100644 --- a/zyngui/multitouch.py +++ b/zyngui/multitouch.py @@ -88,7 +88,7 @@ def __init__(self, slot): @property def position(self): - """Current position of touch event as tuple (x,y)""" + """Current position of touch event as tuple (x,y)""" return (self.x, self.y) @@ -568,7 +568,7 @@ def tag_bind(self, widget, tagOrId, sequence, function, add=False): widget - Canvas widget tagOrId - Tag or object ID to bind event to - sequence - Event sequence to bind ["press" "motion" | "release" | "horizontal_drag"] + sequence - Event sequence to bind ["press" | "motion" | "release" | "horizontal_drag"] function - Callback function add - True to append the binding otherwise remove existing bindings (default) @@ -601,7 +601,7 @@ def tag_unbind(self, widget, tagOrId, sequence, function=None): widget - Canvas widget tagOrId - Tag or object ID to bind event to - sequence - Event sequence to bind ["press" "motion" | "release" | "horizontal_drag"] + sequence - Event sequence to bind ["press" | "motion" | "release" | "horizontal_drag"] function - Callback function (Optional - default None=remove all bindings) """ diff --git a/zyngui/zynthian_gui.py b/zyngui/zynthian_gui.py index 292a1d508..e5f6991d8 100644 --- a/zyngui/zynthian_gui.py +++ b/zyngui/zynthian_gui.py @@ -61,10 +61,14 @@ from zyngui.zynthian_gui_admin import zynthian_gui_admin from zyngui.zynthian_gui_snapshot import zynthian_gui_snapshot from zyngui.zynthian_gui_chain_options import zynthian_gui_chain_options +from zyngui.zynthian_gui_chain_manager import zynthian_gui_chain_manager +from zyngui.zynthian_gui_add_chain import zynthian_gui_add_chain from zyngui.zynthian_gui_processor_options import zynthian_gui_processor_options from zyngui.zynthian_gui_engine import zynthian_gui_engine from zyngui.zynthian_gui_midi_chan import zynthian_gui_midi_chan from zyngui.zynthian_gui_midi_cc import zynthian_gui_midi_cc +from zyngui.zynthian_gui_midi_cc_range import zynthian_gui_midi_cc_range +from zyngui.zynthian_gui_midi_cc_single import zynthian_gui_midi_cc_single from zyngui.zynthian_gui_midi_prog import zynthian_gui_midi_prog from zyngui.zynthian_gui_midi_key_range import zynthian_gui_midi_key_range from zyngui.zynthian_gui_audio_out import zynthian_gui_audio_out @@ -81,11 +85,10 @@ from zyngui.zynthian_gui_main_menu import zynthian_gui_main_menu from zyngui.zynthian_gui_chain_menu import zynthian_gui_chain_menu from zyngui.zynthian_gui_midi_recorder import zynthian_gui_midi_recorder -from zyngui.zynthian_gui_zynpad import zynthian_gui_zynpad from zyngui.zynthian_gui_arranger import zynthian_gui_arranger -from zyngui.zynthian_gui_patterneditor import zynthian_gui_patterneditor +from zyngui.zynthian_gui_pated_notes import zynthian_gui_pated_notes +from zyngui.zynthian_gui_pated_cc import zynthian_gui_pated_cc from zyngui.zynthian_gui_mixer import zynthian_gui_mixer -from zyngui.zynthian_gui_tempo import zynthian_gui_tempo from zyngui.zynthian_gui_brightness_config import zynthian_gui_brightness_config from zyngui.zynthian_gui_touchscreen_calibration import zynthian_gui_touchscreen_calibration from zyngui.zynthian_gui_cv_config import zynthian_gui_cv_config @@ -100,14 +103,32 @@ # Zynthian Main GUI Class # ------------------------------------------------------------------------------- +class DebugLock(): + """ Helper debug class to log mutex lock access + Replace Lock() with DebugLock() when debugging lock issues, + e.g. self.screen_lock = DebugLock() + """ + def __init__(self): + self.lock = Lock() + + def acquire(self): + traceback.print_stack(limit=2) + self.lock.acquire() + + def release(self): + traceback.print_stack(limit=2) + self.lock.release() class zynthian_gui: # Subsignals are defined inside each module. Here we define GUI subsignals: - SS_SHOW_SCREEN = 0 + + SS_GUI_SHOW_SCREEN = 0 SS_GUI_SHOW_SIDEBAR = 1 SS_GUI_CONTROL_MODE = 2 SS_GUI_SHOW_FILE_SELECTOR = 3 - SS_GUI_SHOW_MESSAGE = 4 + SS_GUI_TOGGLE_ALT_MODE = 4 + SS_GUI_SHOW_MESSAGE = 5 + SS_GUI_LAUNCHER_MODE = 6 # Screen Modes SCREEN_HMODE_NONE = 0 @@ -157,8 +178,9 @@ def __init__(self): self.status_counter = 0 - self.modify_chain_status = {"midi_thru": False, "audio_thru": False, "parallel": False} + self.modify_chain_status = {"midi_thru": False, "audio_thru": False} + self.capture_log = False self.capture_log_ts0 = None self.capture_log_fname = None self.capture_ffmpeg_proc = None @@ -196,6 +218,7 @@ def start_capture_log(self, title="ui_sesion"): now = datetime.now() self.capture_log_ts0 = now self.capture_log_fname = "{}-{}".format(title, now.strftime("%Y%m%d%H%M%S")) + self.capture_log = True self.start_capture_ffmpeg() if self.wsleds: self.wsleds.reset_last_state() @@ -223,6 +246,7 @@ def stop_capture_ffmpeg(self): def stop_capture_log(self): self.stop_capture_ffmpeg() + self.capture_log = False self.capture_log_fname = None self.capture_log_ts0 = None @@ -391,34 +415,51 @@ def osc_cb_all(self, path, args, types, src): zynautoconnect.request_audio_connect() zynautoconnect.request_midi_connect() elif parts[1] in ("MIXER", "DAWOSC"): + #TODO: Fix OSC control of zynmixer self.state_manager.set_event_flag() part2 = parts[2] if part2 in ("HEARTBEAT", "SETUP"): if src.hostname not in self.osc_clients: try: - if self.state_manager.zynmixer.add_osc_client(src.hostname) < 0: + if self.state_manager.zynmixer_chan.add_osc_client(src.hostname) < 0: + logging.warning("Failed to add OSC client registration {}".format(src.hostname)) + return + if self.state_manager.zynmixer_bus.add_osc_client(src.hostname) < 0: logging.warning("Failed to add OSC client registration {}".format(src.hostname)) return except: logging.warning("Error trying to add OSC client registration {}".format(src.hostname)) return self.osc_clients[src.hostname] = monotonic() - self.state_manager.zynmixer.enable_dpm(0, self.state_manager.zynmixer.MAX_NUM_CHANNELS - 2, True) + self.state_manager.zynmixer_chan.enable_dpm(True) + self.state_manager.zynmixer_bus.enable_dpm(True) else: - if part2[:6] == "VOLUME": - self.state_manager.zynmixer.set_level(int(part2[6:]), float(args[0])) - if part2[:5] == "FADER": - self.state_manager.zynmixer.set_level(int(part2[5:]), float(args[0])) - if part2[:5] == "LEVEL": - self.state_manager.zynmixer.set_level(int(part2[5:]), float(args[0])) - elif part2[:7] == "BALANCE": - self.state_manager.zynmixer.set_balance(int(part2[7:]), float(args[0])) - elif part2[:4] == "MUTE": - self.state_manager.zynmixer.set_mute(int(part2[4:]), int(args[0])) - elif part2[:4] == "SOLO": - self.state_manager.zynmixer.set_solo(int(part2[4:]), int(args[0])) - elif part2[:4] == "MONO": - self.state_manager.zynmixer.set_mono(int(part2[4:]), int(args[0])) + mixer, param = part2.split("/") + if mixer == "bus": + zynmixer = self.state_manager.zynmixer_bus + else: + zynmixer = self.state_manager.zynmixer_chan + if param[:6] == "VOLUME": + zynmixer.set_level( + int(part2[6:]), float(args[0])) + if param[:5] == "FADER": + zynmixer.set_level( + int(part2[5:]), float(args[0])) + if param[:5] == "LEVEL": + zynmixer.set_level( + int(part2[5:]), float(args[0])) + elif param[:7] == "BALANCE": + zynmixer.set_balance( + int(part2[7:]), float(args[0])) + elif param[:4] == "MUTE": + zynmixer.set_mute( + int(part2[4:]), int(args[0])) + elif param[:4] == "SOLO": + zynmixer.set_solo( + int(part2[4:]), int(args[0])) + elif param[:4] == "MONO": + zynmixer.set_mono( + int(part2[4:]), int(args[0])) else: logging.warning(f"Not supported OSC call '{path}'") @@ -443,10 +484,14 @@ def create_screens(self): self.screens['details'] = zynthian_gui_details() self.screens['engine'] = zynthian_gui_engine() self.screens['chain_options'] = zynthian_gui_chain_options() + self.screens['chain_manager'] = zynthian_gui_chain_manager() + self.screens['add_chain'] = zynthian_gui_add_chain() self.screens['processor_options'] = zynthian_gui_processor_options() self.screens['snapshot'] = zynthian_gui_snapshot() self.screens['midi_chan'] = zynthian_gui_midi_chan() self.screens['midi_cc'] = zynthian_gui_midi_cc() + self.screens['midi_cc_range'] = zynthian_gui_midi_cc_range() + self.screens['midi_cc_single'] = zynthian_gui_midi_cc_single() self.screens['midi_prog'] = zynthian_gui_midi_prog() self.screens['midi_key_range'] = zynthian_gui_midi_key_range() self.screens['audio_out'] = zynthian_gui_audio_out() @@ -459,9 +504,9 @@ def create_screens(self): self.screens['midi_profile'] = zynthian_gui_midi_profile() self.screens['zs3'] = zynthian_gui_zs3() self.screens['zs3_options'] = zynthian_gui_zs3_options() - self.screens['tempo'] = zynthian_gui_tempo() + self.screens['tempo'] = self.screens['control'] self.screens['admin'] = zynthian_gui_admin() - self.screens['audio_mixer'] = zynthian_gui_mixer() + self.screens['mixer'] = zynthian_gui_mixer() # Create the right main menu screen if zynthian_gui_config.check_wiring_layout(["Z2", "V5"]): @@ -473,15 +518,21 @@ def create_screens(self): self.screens['audio_player'] = self.screens['control'] self.screens['midi_recorder'] = zynthian_gui_midi_recorder() self.screens['alsa_mixer'] = self.screens['control'] - self.screens['zynpad'] = zynthian_gui_zynpad() - self.screens['arranger'] = zynthian_gui_arranger() - self.screens['pattern_editor'] = zynthian_gui_patterneditor() + self.screens['launcher'] = self.screens['mixer'] + #self.screens['arranger'] = zynthian_gui_arranger() + self.screens['pattern_editor'] = zynthian_gui_pated_notes() + self.screens['pated_cc'] = zynthian_gui_pated_cc() self.screens['wifi'] = zynthian_gui_wifi() self.screens['bluetooth'] = zynthian_gui_bluetooth() self.screens['brightness_config'] = zynthian_gui_brightness_config() self.screens['touchscreen_calibration'] = zynthian_gui_touchscreen_calibration() self.screens['control_test'] = zynthian_gui_control_test() + # Root screen + self.screens['root'] = self.screens['mixer'] + self.screens['launcher'] = self.screens['mixer'] + #self.screens['root'] = self.screens['none'] + # Create Zynaptik-related screens try: if callable(lib_zyncore.init_zynaptik): @@ -546,7 +597,7 @@ def start_task(self): if zynthian_gui_config.control_test_enabled: init_screen = "control_test" else: - init_screen = "main_menu" + init_screen = "add_chain" # Try to load "last_state" snapshot... if zynthian_gui_config.restore_last_state: snapshot_loaded = self.state_manager.load_last_state_snapshot() @@ -555,7 +606,7 @@ def start_task(self): snapshot_loaded = self.state_manager.load_default_snapshot() if snapshot_loaded: - init_screen = "audio_mixer" + init_screen = "root" else: # Init MIDI Subsystem => MIDI Profile self.state_manager.init_midi() @@ -570,6 +621,8 @@ def start_task(self): # Show initial screen self.show_screen(init_screen, zynthian_gui.SCREEN_HMODE_RESET) + #self.screens['root'] = self.screens['mixer'] + def hide_screens(self, exclude=None): if not exclude: exclude = self.current_screen @@ -589,12 +642,28 @@ def show_screen(self, screen=None, hmode=SCREEN_HMODE_ADD, params=None): if screen is None: if self.current_screen: - screen = self.current_screen + screen = self.get_current_screen() else: - screen = "audio_mixer" + screen = "root" + if screen == "root": + try: + if self.screens["mixer"].launcher_mode: + screen = "launcher" + else: + screen = "mixer" + except: + logging.warning("Mixer view not yet created!") + screen = "mixer" + elif screen == "mixer": + self.screens[screen].set_launcher_mode(False) + elif screen == "launcher": + self.screens[screen].set_launcher_mode(True) elif screen == "alsa_mixer": self.state_manager.alsa_mixer_processor.refresh_controllers(params) self.current_processor = self.state_manager.alsa_mixer_processor + elif screen == "tempo": + self.state_manager.tempo_processor.refresh_controllers(params) + self.current_processor = self.state_manager.tempo_processor elif screen == "audio_player": if self.state_manager.audio_player: self.current_processor = self.state_manager.audio_player @@ -609,9 +678,15 @@ def show_screen(self, screen=None, hmode=SCREEN_HMODE_ADD, params=None): if screen not in ("bank", "preset", "option"): self.chain_manager.restore_presets() - if not self.screens[screen].build_view(): + root_screens = ("root", "mixer", "launcher") + if screen in root_screens and self.current_screen in root_screens: + dummy_show = True + else: + dummy_show = False + + if not dummy_show and not self.screens[screen].build_view(): self.screen_lock.release() - # self.show_screen_reset("audio_mixer") + # self.show_screen_reset("mixer") self.close_screen() return @@ -628,10 +703,12 @@ def show_screen(self, screen=None, hmode=SCREEN_HMODE_ADD, params=None): if self.current_screen != screen: #logging.debug(f"SHOW_SCREEN {screen}") - self.screens[screen].show() + if not dummy_show: + self.screens[screen].show() self.current_screen = screen - self.hide_screens(exclude=screen) - zynsigman.send(zynsigman.S_GUI, self.SS_SHOW_SCREEN, screen=screen) + if not dummy_show: + self.hide_screens(exclude=screen) + zynsigman.send(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_SCREEN, screen=screen) self.screen_lock.release() @@ -657,16 +734,18 @@ def close_screen(self, screen=None): """ Closes the current screen or optionally the specified screen """ if screen is None: - screen = self.current_screen + screen = self.get_current_screen() self.prune_screen_history(screen, soft=False) try: last_screen = self.screen_history.pop() except: - last_screen = "audio_mixer" + last_screen = "root" if last_screen not in self.screens: logging.error(f"Can't back to screen '{last_screen}'. It doesn't exist!") - last_screen = "audio_mixer" + last_screen = "root" + elif last_screen in ("mixer", "launcher"): + last_screen = "root" logging.debug(f"CLOSE SCREEN '{self.current_screen}' => Back to '{last_screen}'") self.show_screen(last_screen) @@ -705,6 +784,13 @@ def toggle_screen(self, screen, hmode=SCREEN_HMODE_ADD): else: self.close_screen() + def get_current_screen(self): + if self.current_screen in ("mixer", "launcher", "root"): + screen = ("mixer", "launcher")[self.screens["mixer"].launcher_mode] + else: + screen = self.current_screen + return screen + def get_current_screen_obj(self): try: return self.screens[self.current_screen] @@ -719,16 +805,16 @@ def show_confirm(self, text, callback=None, cb_params=None): self.screen_lock.release() def show_keyboard(self, callback, text="", max_chars=None): - self.screen_lock.acquire() self.screens['keyboard'].set_mode(zynthian_gui_keyboard.OSK_QWERTY) + self.screen_lock.acquire() self.screens['keyboard'].show(callback, text, max_chars) self.current_screen = 'keyboard' self.hide_screens(exclude='keyboard') self.screen_lock.release() def show_numpad(self, callback, text="", max_chars=None): - self.screen_lock.acquire() self.screens['keyboard'].set_mode(zynthian_gui_keyboard.OSK_NUMPAD) + self.screen_lock.acquire() self.screens['keyboard'].show(callback, text, max_chars) self.current_screen = 'keyboard' self.hide_screens(exclude='keyboard') @@ -763,36 +849,36 @@ def show_splash(self, text): self.screen_lock.release() def show_loading(self, title="", details=""): - self.screen_lock.acquire() self.screens['loading'].set_title(title) self.screens['loading'].set_details(details) + self.screen_lock.acquire() self.screens['loading'].show() self.current_screen = 'loading' self.hide_screens(exclude='loading') self.screen_lock.release() def show_loading_error(self, title="", details=""): - self.screen_lock.acquire() self.screens['loading'].set_error(title) self.screens['loading'].set_details(details) + self.screen_lock.acquire() self.screens['loading'].show() self.current_screen = 'loading' self.hide_screens(exclude='loading') self.screen_lock.release() def show_loading_warning(self, title="", details=""): - self.screen_lock.acquire() self.screens['loading'].set_warning(title) self.screens['loading'].set_details(details) + self.screen_lock.acquire() self.screens['loading'].show() self.current_screen = 'loading' self.hide_screens(exclude='loading') self.screen_lock.release() def show_loading_success(self, title="", details=""): - self.screen_lock.acquire() self.screens['loading'].set_warning(title) self.screens['loading'].set_details(details) + self.screen_lock.acquire() self.screens['loading'].show() self.current_screen = 'loading' self.hide_screens(exclude='loading') @@ -819,13 +905,13 @@ def calibrate_touchscreen(self): def brightness_config(self): self.show_screen('brightness_config') - def midi_in_config(self): - self.screens['midi_config'].set_chain(None) + def midi_in_config(self, chain=None): + self.screens['midi_config'].set_chain(chain) self.screens['midi_config'].input = True self.show_screen('midi_config') - def midi_out_config(self): - self.screens['midi_config'].set_chain(None) + def midi_out_config(self, chain=None): + self.screens['midi_config'].set_chain(chain) self.screens['midi_config'].input = False self.show_screen('midi_config') @@ -854,31 +940,32 @@ def modify_chain(self, status=None): # Modifying an existing chain if "processor" in self.modify_chain_status: # Replacing processor in existing chain - chain = self.chain_manager.get_chain( - self.modify_chain_status["chain_id"]) + chain = self.chain_manager.get_chain(self.modify_chain_status["chain_id"]) old_processor = self.modify_chain_status["processor"] if chain and old_processor: slot = chain.get_slot(old_processor) - processor = self.chain_manager.add_processor( - self.modify_chain_status["chain_id"], self.modify_chain_status["engine"], True, slot) + processor = self.chain_manager.add_processor(self.modify_chain_status["chain_id"], + self.modify_chain_status["engine"], slot) if processor: - self.chain_manager.remove_processor( - self.modify_chain_status["chain_id"], old_processor) + self.chain_manager.remove_processor(self.modify_chain_status["chain_id"], old_processor) + chain.rebuild_graph() + zynautoconnect.autoconnect() self.close_screen("loading") - self.chain_control( - self.modify_chain_status["chain_id"], processor, force_bank_preset=True) + self.chain_control(self.modify_chain_status["chain_id"], processor, force_bank_preset=True) else: # Adding processor to existing chain - parallel = "parallel" in self.modify_chain_status and self.modify_chain_status["parallel"] - post_fader = "post_fader" in self.modify_chain_status and self.modify_chain_status["post_fader"] + if "slot" in self.modify_chain_status: + slot = self.modify_chain_status["slot"] + else: + slot = None processor = self.chain_manager.add_processor(self.modify_chain_status["chain_id"], - self.modify_chain_status["engine"], - parallel=parallel, post_fader=post_fader) + self.modify_chain_status["engine"], slot) if processor: + zynautoconnect.autoconnect() self.close_screen("loading") self.chain_control(self.modify_chain_status["chain_id"], processor, force_bank_preset=True) else: - self.show_screen_reset("audio_mixer") + self.show_screen_reset("root") else: # Creating a new chain if "midi_chan" in self.modify_chain_status: @@ -887,35 +974,47 @@ def modify_chain(self, status=None): self.modify_chain_status["midi_thru"] = False if "audio_thru" not in self.modify_chain_status: self.modify_chain_status["audio_thru"] = False + if "mixbus" not in self.modify_chain_status: + self.modify_chain_status["mixbus"] = False # Detect MOD-UI special chain and assign dedicated zmop index if self.modify_chain_status["engine"] == "MD": zmop_index = ZMOP_MOD_INDEX else: zmop_index = None + if "pos" in self.modify_chain_status: + pos = self.modify_chain_status["pos"] + else: + pos = None chain_id = self.chain_manager.add_chain( None, self.modify_chain_status["midi_chan"], self.modify_chain_status["midi_thru"], self.modify_chain_status["audio_thru"], - zmop_index=zmop_index + zmop_index, + chain_pos=pos ) if chain_id is None: - self.show_screen_reset("audio_mixer") + self.show_screen_reset("root") self.show_info("Failed to create chain", 1500) return - processor = self.chain_manager.add_processor( - chain_id, - self.modify_chain_status["engine"] - ) - # self.modify_chain_status = {"midi_thru": False, "audio_thru": False, "parallel": False} - if processor: + processor = self.chain_manager.add_processor(chain_id, self.modify_chain_status["engine"]) + if self.chain_manager.chains[chain_id].synth_slots or self.modify_chain_status["audio_thru"]: + if self.modify_chain_status["mixbus"]: + am_proc = self.chain_manager.add_processor(chain_id, "MR") + self.chain_manager.set_chain_title(chain_id, am_proc.name) + else: + am_proc = self.chain_manager.add_processor(chain_id, "MI") + self.chain_manager.rebuild_optimisation_cache() + zynautoconnect.request_audio_connect(True) + zynautoconnect.request_midi_connect(True) + if processor and processor.eng_code != "CL": self.close_screen("loading") - self.chain_control( - chain_id, processor, force_bank_preset=True) + self.screen_history = [] + self.chain_control(chain_id, processor, force_bank_preset=True) else: # Created empty chain # self.chain_manager.set_active_chain_by_id(chain_id) - self.show_screen_reset("audio_mixer") + self.show_screen_reset("chain_manager") else: # Select MIDI channel logging.debug(self.modify_chain_status) @@ -934,9 +1033,9 @@ def modify_chain(self, status=None): # TODO: Offer type selection pass - def chain_control(self, chain_id=None, processor=None, hmode=SCREEN_HMODE_RESET, force_bank_preset=False): + def chain_control(self, chain_id=None, processor=None, hmode=SCREEN_HMODE_ADD, force_bank_preset=False): if chain_id is None: - chain_id = self.chain_manager.active_chain_id + chain_id = self.chain_manager.active_chain.chain_id else: self.chain_manager.set_active_chain_by_id(chain_id) @@ -1017,12 +1116,21 @@ def show_favorites(self): curproc.set_show_fav_presets(True) self.show_screen("preset") + def set_current_processor(self, processor): + self.current_processor = processor + try: + self.chain_manager.active_chain.set_current_processor(processor) + except: + pass + def get_current_processor(self): - """Get the currently selected processor object""" + """ Get the currently selected processor object + This may not be within a chain. + """ if self.current_processor: return self.current_processor try: - return self.chain_manager.get_active_chain().current_processor + return self.chain_manager.active_chain.current_processor except: return None @@ -1036,25 +1144,35 @@ def get_current_processor_wait(self): sleep(0.1) def get_alt_mode(self): + try: + return self.screens[self.current_screen].get_alt_mode() + except: + return self.alt_mode + + def get_global_alt_mode(self): return self.alt_mode + def set_global_alt_mode(self, alt_mode): + self.alt_mode = alt_mode + zynsigman.send(zynsigman.S_GUI, zynsigman.SS_GUI_TOGGLE_ALT_MODE, alt_mode=self.alt_mode) + def clean_all(self): if self.chain_manager.get_chain_count() > 1: self.state_manager.save_last_state_snapshot() self.state_manager.clean_all() - self.show_screen_reset('main_menu') + self.show_screen_reset('chain_manager') def clean_chains(self): if self.chain_manager.get_chain_count() > 1: self.state_manager.save_last_state_snapshot() self.state_manager.clean_chains() - self.show_screen_reset('main_menu') + self.show_screen_reset('chain_manager') def clean_sequences(self): if self.chain_manager.get_chain_count() > 1: self.state_manager.save_last_state_snapshot() self.state_manager.clean_sequences() - self.show_screen_reset('zynpad') + self.show_screen_reset('launcher') # ------------------------------------------------------------------- # Callable UI Actions @@ -1067,22 +1185,28 @@ def get_cuia_list(cls): def callable_ui_action(self, cuia, params=None): logging.debug("CUIA '{}' => {}".format(cuia, params)) cuia_func_name = "cuia_" + cuia.lower() - # First try screen defined cuia function done = False - cuia_func = getattr(self.get_current_screen_obj(), - cuia_func_name, None) - if callable(cuia_func): - if cuia_func(params): + screen_obj = self.get_current_screen_obj() + # First, try widget defined cuia function + widget_obj = getattr(screen_obj, "current_widget", None) + if widget_obj: + cuia_func = getattr(widget_obj, cuia_func_name, None) + if callable(cuia_func) and cuia_func(params): done = True + # Second, try screen defined cuia function if not done: - # else, call global function - cuia_func = getattr(self, cuia_func_name, None) - if callable(cuia_func): - cuia_func(params) - else: - logging.error("Unknown CUIA '{}'".format(cuia)) + cuia_func = getattr(screen_obj, cuia_func_name, None) + if callable(cuia_func) and cuia_func(params): + done = True + # Third, call default CUIA function (defined in this class) + if not done: + cuia_func = getattr(self, cuia_func_name, None) + if callable(cuia_func): + cuia_func(params) + else: + logging.error("Unknown CUIA '{}'".format(cuia)) # Capture CUIA for UI log - if self.capture_log_fname: + if self.capture_log: self.write_capture_log("CUIA:{},{}".format(cuia, str(params))) def callable_ui_action_params(self, cuia_str): @@ -1103,10 +1227,7 @@ def cuia_test_mode(self, params): logging.warning('TEST_MODE: {}'.format(params)) def cuia_toggle_alt_mode(self, params=None): - if self.alt_mode: - self.alt_mode = False - else: - self.alt_mode = True + self.set_global_alt_mode(not self.alt_mode) def cuia_help(self, params=None): self.show_help(params) @@ -1166,6 +1287,7 @@ def cuia_all_sounds_off(self, params=None): self.state_manager.all_sounds_off() sleep(0.1) self.state_manager.raw_all_notes_off() + zynautoconnect.reset_xruns() try: self.screens[self.current_screen].set_title("ALL SOUNDS OFF", None, None, 1) except: @@ -1175,7 +1297,7 @@ def cuia_clean_all(self, params=None): if params == ['CONFIRM']: self.clean_all() # TODO: Should send signal so that UI can react - self.show_screen_reset('main_menu') + self.show_screen_reset('chain_manager') # Audio & MIDI Recording/Playback actions def cuia_start_audio_record(self, params=None): @@ -1258,7 +1380,7 @@ def cuia_toggle_play(self, params=None): self.cuia_toggle_audio_play() def cuia_tempo(self, params=None): - self.screens["tempo"].tap() + self.state_manager.zynseq.tap_tempo() if self.current_screen != "tempo": self.show_screen("tempo") @@ -1270,7 +1392,7 @@ def cuia_set_tempo(self, params=None): def cuia_toggle_seq(self, params=None): try: - self.state_manager.zynseq.libseq.togglePlayState(self.state_manager.zynseq.bank, int(params[0])) + self.state_manager.zynseq.libseq.togglePlayState(self.state_manager.zynseq.scene, int(params[0]), int(params[1])) except (AttributeError, TypeError): pass @@ -1294,7 +1416,7 @@ def cuia_tempo_down(self, params=None): self.state_manager.zynseq.set_tempo(self.state_manager.zynseq.get_tempo() - 1) def cuia_tap_tempo(self, params=None): - self.screens["tempo"].tap() + self.state_manager.zynseq.tap_tempo() # Zynpot & Zynswitch emulation CUIAs (low level) def cuia_zynpot(self, params=None): @@ -1302,6 +1424,8 @@ def cuia_zynpot(self, params=None): i = int(params[0]) d = int(params[1]) self.get_current_screen_obj().zynpot_cb(i, d) + if self.capture_log: + self.write_capture_log("ZYNPOT:{},{}".format(i, d)) except IndexError: logging.error("zynpot requires 2 parameters: index, delta, not {params}") return @@ -1370,7 +1494,29 @@ def cuia_select(self, params=None): except (AttributeError, TypeError): pass - # Screen/Mode management CUIAs + def cuia_mixer(self, params): + """ Set mixer control + + params[0]: Index of mixer strip in display order (-1 for main mixbus) + params[1]: parameter symbol + params[2]: parameter value + """ + try: + if params[0] == -1: + chain_id = 0 + else: + chain_id = list(self.chain_manager.chains)[params[0]] + chain = self.chain_manager.chains[chain_id] + action = params[1] + value = params[2] + chain.zynmixer_proc.controllers_dict[action].set_value(value) + except: + logging.warning(f"Failed to set mixer - bad params? {params}") + + # ------------------------------------------------------------------- + # Screen management CUIAs + # ------------------------------------------------------------------- + def cuia_toggle_screen(self, params=None): if params: self.toggle_screen(params[0]) @@ -1385,8 +1531,14 @@ def cuia_screen_main_menu(self, params=None): def cuia_screen_admin(self, params=None): self.show_screen("admin") + def cuia_screen_mixer(self, params=None): + self.show_screen_reset("mixer") + + def cuia_screen_chain_manager(self, params=None): + self.show_screen("chain_manager") + def cuia_screen_audio_mixer(self, params=None): - self.show_screen("audio_mixer") + self.show_screen_reset("mixer") def cuia_screen_snapshot(self, params=None): self.show_screen("snapshot") @@ -1401,15 +1553,21 @@ def cuia_screen_midi_recorder(self, params=None): def cuia_screen_alsa_mixer(self, params=None): self.show_screen("alsa_mixer", hmode=zynthian_gui.SCREEN_HMODE_RESET) + def cuia_screen_launcher(self, params=None): + if self.current_screen == "mixer" and self.screens["mixer"].launcher_mode: + self.show_screen("pattern_editor") + else: + self.show_screen_reset("launcher") + def cuia_screen_zynpad(self, params=None): - self.show_screen("zynpad") + self.show_screen("launcher") def cuia_screen_pattern_editor(self, params=None): success = False - if self.current_screen in ["arranger", "zynpad"]: + if self.current_screen == "launcher": + success = self.screens['launcher'].edit_clip() + elif self.current_screen == "arranger": success = self.screens[self.current_screen].show_pattern_editor() - if not success: - success = self.screens['zynpad'].show_pattern_editor() if not success: self.show_screen("pattern_editor") @@ -1426,6 +1584,17 @@ def cuia_screen_clean(self, params=None): sleep(1) self.state_manager.end_busy("clean_screen") + def cuia_refresh_screen(self, params=None): + if params is None or self.current_screen in params: + self.screens[self.current_screen].build_view() + self.screen_lock.acquire() + self.screens[self.current_screen].show() + self.screen_lock.release() + + # ------------------------------------------------------------------- + # Menu, Chain Control & Options, Bank/Presets: + # ------------------------------------------------------------------- + def cuia_chain_control(self, params=None): try: # Select chain by index @@ -1435,10 +1604,7 @@ def cuia_chain_control(self, params=None): else: chain_id = self.chain_manager.get_chain_id_by_index(index - 1) except: - if self.alt_mode: - chain_id = 0 - else: - chain_id = self.chain_manager.active_chain_id + chain_id = self.chain_manager.active_chain.chain_id self.chain_control(chain_id) cuia_layer_control = cuia_chain_control @@ -1461,7 +1627,7 @@ def cuia_chain_options(self, params=None): else: chain_id = self.chain_manager.get_chain_id_by_index(params[0] - 1) except: - chain_id = self.chain_manager.active_chain_id + chain_id = self.chain_manager.active_chain.chain_id if chain_id is not None: self.screens['chain_options'].setup(chain_id) @@ -1471,12 +1637,11 @@ def cuia_chain_options(self, params=None): def cuia_menu(self, params=None): if self.current_screen != "alsa_mixer": - toggle_menu_func = getattr( - self.screens[self.current_screen], "toggle_menu", None) + toggle_menu_func = getattr(self.screens[self.current_screen], "toggle_menu", None) if callable(toggle_menu_func): toggle_menu_func() return - self.toggle_screen("main_menu", hmode=zynthian_gui.SCREEN_HMODE_ADD) + self.toggle_screen("chain_manager", hmode=zynthian_gui.SCREEN_HMODE_ADD) def cuia_bank_preset(self, params=None): if self.is_shown_alsa_mixer(): @@ -1531,6 +1696,13 @@ def cuia_preset_fav(self, params=None): # ZS3 management CUIAs: # ------------------------------------------------------------------- + def cuia_zs3_save(self, params=None): + if len(params) >= 1: + if isinstance(params[0], int): + self.state_manager.save_zs3_by_index(params[0]) + else: + self.state_manager.save_zs3(params[0]) + def cuia_zs3_load(self, params=None): if len(params) >= 1: if isinstance(params[0], int): @@ -1544,6 +1716,10 @@ def cuia_zs3_next(self, params=None): def cuia_zs3_prev(self, params=None): self.state_manager.load_prev_zs3() + # ------------------------------------------------------------------- + # MIDI Learn CUIAS: + # ------------------------------------------------------------------- + def cuia_enable_midi_learn_cc(self, params=None): # TODO: Find zctrl if len(params) == 2: @@ -1606,12 +1782,23 @@ def cuia_midi_unlearn_control(self, params=None): # Unlearn all mixer controls def cuia_midi_unlearn_mixer(self, params=None): - try: - self.screens['audio_mixer'].midi_unlearn_all() - except (AttributeError, TypeError) as err: - logging.error(err) + for chain in self.chain_manager.chains.values(): + if chain.zynmixer_proc: + self.chain_manager.clean_midi_learn(chain.zynmixer_proc) + def cuia_midi_unlearn_node(self, params=None): + if params: + self.chain_manager.remove_midi_learn([params[0], params[1]]) + + def cuia_midi_unlearn_chain(self, params=None): + if params: + self.chain_manager.clean_midi_learn(params[0]) + else: + self.chain_manager.clean_midi_learn(self.chain_manager.active_chain.chain_id) + + # ------------------------------------------------------------------- # Z2 knob touch + # ------------------------------------------------------------------- def cuia_z2_zynpot_touch(self, params=None): if params: try: @@ -1620,78 +1807,25 @@ def cuia_z2_zynpot_touch(self, params=None): pass # TODO: Should all screens be derived from base? - # V5 knob click + # ------------------------------------------------------------------- + # V5 knob's switch action defaults + # ------------------------------------------------------------------- def cuia_v5_zynpot_switch(self, params): i = params[0] t = params[1].upper() - if t == "L": if self.state_manager.zctrl_x and self.state_manager.zctrl_y: self.show_screen("control_xy") - elif self.current_screen in ("control", "alsa_mixer", "audio_player"): - # if i < 3 and t == 'S': - if t == 'S': - if self.screens[self.current_screen].mode == 'select': - self.zynswitch_short(i) - else: - self.screens[self.current_screen].toggle_midi_learn(i) - return - elif t == 'B': - self.screens[self.current_screen].midi_learn_options(i) - return - elif self.current_screen == "engine": - if i == 2 and t == 'S': - self.zynswitch_short(i) - return - elif self.current_screen == "audio_mixer": - if t == 'S': - self.zynswitch_short(i) - return - elif self.current_screen == "zynpad": - if i == 2 and t == 'S': - self.zynswitch_short(i) - return - elif self.current_screen == "pattern_editor": - if i == 0: - if t == 'S' or t == 'B': - self.show_screen("arranger") - return - elif i == 1: - if t == 'S' or t == 'B': - self.screens["pattern_editor"].reset_grid_zoom() - return - elif i == 2: - if t == 'S' or t == 'B': - self.zynswitch_bold(3) - return - elif self.current_screen == "arranger": - if i == 0: - if t == 'S' or t == 'B': - self.show_screen("pattern_editor") - return - elif i == 1: - return - elif i == 2: - return - if i == 3: + elif i == 3: if t == 'S': self.zynswitch_short(i) - return elif t == 'B': self.zynswitch_bold(i) - return - - def cuia_midi_unlearn_node(self, params=None): - if params: - self.chain_manager.remove_midi_learn([params[0], params[1]]) - - def cuia_midi_unlearn_chain(self, params=None): - if params: - self.chain_manager.clean_midi_learn(params[0]) - else: - self.chain_manager.clean_midi_learn(self.chain_manager.active_chain_id) + # ------------------------------------------------------------------- # MIDI CUIAs + # ------------------------------------------------------------------- + def cuia_program_change(self, params=None): if len(params) > 0: if len(params) > 1: @@ -1723,7 +1857,9 @@ def cuia_zyn_cc(self, params=None): else: lib_zyncore.write_zynmidi_ccontrol_change(chan, cc, int(params[2])) + # ------------------------------------------------------------------- # Common methods to control views derived from zynthian_gui_base + # ------------------------------------------------------------------- def cuia_show_cursor(self, params=None): try: zynthian_gui_config.top.config(cursor="arrow") @@ -1782,6 +1918,10 @@ def cuia_toggle_sidebar(self, params=None): except (AttributeError, TypeError): pass + # ------------------------------------------------------------------- + # Zynaptik config CUIAs (CV/gate, etc.) + # ------------------------------------------------------------------- + def cuia_zynaptik_cvin_set_volts_octave(self, params): try: lib_zyncore.zynaptik_cvin_set_volts_octave(float(params[0])) @@ -1806,13 +1946,6 @@ def cuia_zynaptik_cvout_set_note0(self, params): except Exception as err: logging.debug(err) - def cuia_refresh_screen(self, params=None): - if params is None or self.current_screen in params: - self.screen_lock.acquire() - self.screens[self.current_screen].build_view() - self.screens[self.current_screen].show() - self.screen_lock.release() - # ------------------------------------------------------------------- # Zynswitch Event Management # ------------------------------------------------------------------- @@ -1843,14 +1976,14 @@ def custom_switch_ui_action(self, i, t): return True def is_current_screen_menu(self): - if self.current_screen in ("main_menu", "engine", "midi_cc", "midi_chan", "midi_key_range", "audio_in", + if self.current_screen in ("main_menu", "engine", "chain_manager", "midi_cc", "midi_chan", "midi_key_range", "audio_in", "audio_out", "midi_prog") or self.current_screen.endswith("_options"): return True if len(self.screen_history) > 1: if self.current_screen == "midi_config" and self.screen_history[-2] != "admin": return True if self.current_screen in ("option", "confirm", "keyboard"): - parent_views = ("arranger", "zynpad", "pattern_editor", "preset", + parent_views = ("arranger", "pattern_editor", "preset", "bank", "main_menu", "chain_options", "processor_options") if self.screen_history[-1] in parent_views or self.screen_history[-2] in parent_views: return True @@ -1884,6 +2017,27 @@ def check_current_screen_switch(self, action_config): return True return False + def toggle_pated(self): + if self.current_screen == "pated_cc": + pated_screen = "pattern_editor" + elif self.current_screen == "pattern_editor": + pated_screen = "pated_cc" + else: + return + cur_pated = self.get_current_screen_obj() + pated = self.screens[pated_screen] + + pated.refresh_sequence_info() + pated.load_pattern(cur_pated.pattern) + + #pated_obj.bank = cur_pated_obj.bank + #pated_obj.sequence = cur_pated_obj.sequence + #pated_obj.load_pattern(cur_pated_obj.pattern) + #pated_obj.channel = cur_pated_obj.channel + + logging.debug(f"Opening {pated_screen}...") + self.show_screen(pated_screen, self.SCREEN_HMODE_REPLACE) + # ------------------------------------------------------------------- # Switches # ------------------------------------------------------------------- @@ -1959,9 +2113,6 @@ def zynswitch_timing(self, dtus): def zynswitch_push(self, i): self.state_manager.set_event_flag() - if self.capture_log_fname: - self.write_capture_log("ZYNSWITCH:P,{}".format(i)) - if callable(getattr(self.screens[self.current_screen], "switch", None)): if self.screens[self.current_screen].switch(i, 'P'): return True @@ -1977,9 +2128,6 @@ def zynswitch_push(self, i): def zynswitch_long(self, i): logging.debug('Looooooooong Switch '+str(i)) - if self.capture_log_fname: - self.write_capture_log("ZYNSWITCH:L,{}".format(i)) - if callable(getattr(self.screens[self.current_screen], "switch", None)): if self.screens[self.current_screen].switch(i, 'L'): return True @@ -2008,9 +2156,6 @@ def zynswitch_long(self, i): def zynswitch_bold(self, i): logging.debug('Bold Switch '+str(i)) - if self.capture_log_fname: - self.write_capture_log("ZYNSWITCH:B,{}".format(i)) - if callable(getattr(self.screens[self.current_screen], "switch", None)): if self.screens[self.current_screen].switch(i, 'B'): return True @@ -2025,7 +2170,7 @@ def zynswitch_bold(self, i): self.screens[self.current_screen].disable_param_editor() except: pass - self.show_screen_reset('audio_mixer') + self.show_screen_reset('root') return True elif i == 2: @@ -2046,9 +2191,6 @@ def zynswitch_bold(self, i): def zynswitch_short(self, i): logging.debug('Short Switch ' + str(i)) - if self.capture_log_fname: - self.write_capture_log("ZYNSWITCH:S,{}".format(i)) - if callable(getattr(self.screens[self.current_screen], "switch", None)): if self.screens[self.current_screen].switch(i, 'S'): return True @@ -2116,16 +2258,14 @@ def register_signals(self): zynsigman.register(zynsigman.S_MIDI, zynsigman.SS_MIDI_NOTE_OFF, self.cb_midi_note_off) zynsigman.register_queued(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_FILE_SELECTOR, self.cb_show_file_selector) zynsigman.register_queued(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_MESSAGE, self.cb_show_message) - zynsigman.register_queued( - zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.cb_set_active_chain) + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.cb_set_active_chain) def unregister_signals(self): zynsigman.unregister(zynsigman.S_MIDI, zynsigman.SS_MIDI_NOTE_ON, self.cb_midi_note_on) zynsigman.unregister(zynsigman.S_MIDI, zynsigman.SS_MIDI_NOTE_OFF, self.cb_midi_note_off) zynsigman.unregister(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_FILE_SELECTOR, self.cb_show_file_selector) zynsigman.unregister(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_MESSAGE, self.cb_show_message) - zynsigman.unregister( - zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.cb_set_active_chain) + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.cb_set_active_chain) def cb_midi_note_on(self, izmip, chan, note, vel): """Handle MIDI_NOTE_ON signal @@ -2138,8 +2278,7 @@ def cb_midi_note_on(self, izmip, chan, note, vel): # Pattern recording if self.current_screen == 'pattern_editor': - if self.state_manager.zynseq.libseq.isMidiRecord(): - self.screens['pattern_editor'].midi_note_on(note) + self.screens['pattern_editor'].midi_note_on(note) # Preload preset (note-on) # => Now using delayed pre-load (see zynthian_gui_preset.py) #elif self.current_screen == 'preset': @@ -2165,14 +2304,14 @@ def cb_midi_note_off(self, izmip, chan, note, vel): """ # Pattern recording - if self.current_screen == 'pattern_editor' and self.state_manager.zynseq.libseq.isMidiRecord(): + if self.current_screen == 'pattern_editor': self.screens['pattern_editor'].midi_note_off(note) def cb_show_file_selector(self, cb_func, fexts=None, dirnames=None, path=None, preload=False): self.screens["file_selector"].config(cb_func, fexts=fexts, dirnames=dirnames, path=path, preload=preload) self.show_screen("file_selector") - def cb_set_active_chain(self, active_chain): + def cb_set_active_chain(self, active_chain_id): active_chain = self.chain_manager.get_active_chain() if active_chain: self.zynswitches_midi_setup(active_chain.midi_chan) @@ -2206,7 +2345,7 @@ def zynpot_thread_task(self): self.zynpot_lock.release() self.screens[self.current_screen].zynpot_cb(i, dval) self.state_manager.set_event_flag() - if self.capture_log_fname: + if self.capture_log: self.write_capture_log("ZYNPOT:{},{}".format(i, dval)) except Exception as err: pass # Some screens don't use controllers @@ -2235,7 +2374,6 @@ def control_thread_task(self): # Refresh GUI Controllers try: self.screens[self.current_screen].plot_zctrls() - pass except AttributeError: pass except Exception as e: @@ -2312,12 +2450,8 @@ def busy_thread_task(self): self.screens['loading'].set_details(busy_details) else: busy_timeout = 0 - self.screen_lock.acquire() if self.current_screen == "loading": - self.screen_lock.release() self.close_screen("loading") - else: - self.screen_lock.release() try: if self.current_screen: @@ -2402,6 +2536,10 @@ def cuia_thread_task(self): zp_pr_state = 0 if zp_pr_state <= 1: self.zynswitch_long(i) + # Capture log: ZYNSWITCH LONG (autolong) + if self.capture_log: + self.write_capture_log(f"ZYNSWITCH:L,{i}") + # Process events from queue event = self.cuia_queue.get(True, repeat_interval) params = None if isinstance(event, str): @@ -2472,6 +2610,10 @@ def cuia_thread_task(self): if i in zynswitch_repeat: del zynswitch_repeat[i] + # Capture log: ZYNSWITCH + if self.capture_log: + self.write_capture_log(f"ZYNSWITCH:{t},{i}") + elif cuia == "zynpot": # zynpot has parameters: [pot, delta, 'P'|'R']. 'P'&'R' are only used for keybinding to zynpot if len(params) > 2: @@ -2583,12 +2725,14 @@ def osc_timeout(self): if self.osc_clients[client] < self.watchdog_last_check - self.osc_heartbeat_timeout: self.osc_clients.pop(client) try: - self.state_manager.zynmixer.remove_osc_client(client) + self.state_manager.zynmixer_chan.remove_osc_client(client) + self.state_manager.zynmixer_bus.remove_osc_client(client) except: pass - if not self.osc_clients and self.current_screen != "audio_mixer": - self.state_manager.zynmixer.enable_dpm(0, self.state_manager.zynmixer.MAX_NUM_CHANNELS - 2, False) + if not self.osc_clients and self.current_screen not in ("root", "mixer", "launcher"): + self.state_manager.zynmixer_chan.enable_dpm(False) + self.state_manager.zynmixer_bus.enable_dpm(False) # Poll zynthian_gui_config.top.after(self.osc_heartbeat_timeout * 1000, self.osc_timeout) diff --git a/zyngui/zynthian_gui_add_chain.py b/zyngui/zynthian_gui_add_chain.py new file mode 100644 index 000000000..1584f28ee --- /dev/null +++ b/zyngui/zynthian_gui_add_chain.py @@ -0,0 +1,90 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian GUI +# +# Zynthian GUI Add Chain Selector Grid Class +# +# Copyright (C) 2025 Fernando Moyano +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import logging +from zyngui.zynthian_gui_selector_grid import zynthian_gui_selector_grid + + +class zynthian_gui_add_chain(zynthian_gui_selector_grid): + """ + Add Chain Selector presented as a grid of buttons. + """ + def __init__(self): + super().__init__() + + self.columns = 3 + self.pos = 0 + self.reset_config() + + def reset_config(self): + self.config = [{ + "title": "Instrument", + "icon": "midi_instrument.png", + "action": self.zyngui.modify_chain, + "action_params": [{"type": "MIDI Synth", "midi_thru": False, "audio_thru": False, "pos": self.pos}] + }, { + "title": "Audio Input", + "icon": "microphone.png", + "action": self.zyngui.modify_chain, + "action_params": [{"type": "Audio Effect", "midi_thru": False, "audio_thru": True, "pos": self.pos}] + }, { + "title": "Audio Clip", + "icon": "audio.png", + "action": self.zyngui.modify_chain, + "action_params": [{"type": "Audio Generator", "midi_thru": False, "audio_thru": False, "engine": "CL", "midi_chan": None, "pos": self.pos}] + }, { + "title": "MIDI", + "icon": "midi_logo.png", + "action": self.zyngui.modify_chain, + "action_params": [{"type": "MIDI Tool", "midi_thru": True, "audio_thru": False, "pos": self.pos}] + }, { + "title": "MIDI\n+\nAudio", + "icon": "midi_audio.png", + "action": self.zyngui.modify_chain, + "action_params": [{"type": "Audio Effect", "midi_thru": True, "audio_thru": True, "pos": self.pos}] + }, { + "title": "Audio Generator", + "icon": "audio_generator.png", + "action": self.zyngui.modify_chain, + "action_params": [{"type": "Audio Generator", "midi_thru": False, "audio_thru": False, "pos": self.pos}] + }, { + "title": "Special", + "icon": "special_chain.png", + "action": self.zyngui.modify_chain, + "action_params": [{"type": "Special", "midi_thru": True, "audio_thru": True, "pos": self.pos}] + }, { + "title": "Mixbus", + "icon": "effects_loop.png", + "action": self.zyngui.modify_chain, + "action_params": [{"type": "Audio Effect", "midi_thru": False, "audio_thru": True, "mixbus": True, "pos": self.pos}] + }] + + # Setup position and reset + def set_chain_pos(self, pos): + self.pos = pos + + def build_view(self): + self.reset_config() + return super().build_view() diff --git a/zyngui/zynthian_gui_admin.py b/zyngui/zynthian_gui_admin.py index df0ebbe09..6bb2babc7 100644 --- a/zyngui/zynthian_gui_admin.py +++ b/zyngui/zynthian_gui_admin.py @@ -108,6 +108,14 @@ def fill_list(self): self.filling_list = True self.list_data = [] + self.list_data.append((None, 0, "> MIXER")) + self.list_data.append((self.visible_chains, 0, f"Visible Chains ({zynthian_gui_config.visible_mixer_strips})", + ["Quantity of chains shown in mixer", + None])) + self.list_data.append((self.visible_launchers, 0, f"Visible Launchers ({zynthian_gui_config.visible_launchers})", + ["Quantity of launchers shown in mixer", + None])) + self.list_data.append((None, 0, "> MIDI")) self.list_data.append((self.zyngui.midi_in_config, 0, "MIDI Input Devices", ["Configure MIDI input devices.", "midi_input.png"])) @@ -150,12 +158,22 @@ def fill_list(self): display_val = f"+{gtrans}" else: display_val = f"{gtrans}" - self.list_data.append((self.edit_global_transpose, 0, f"[{display_val}] Global Transpose", + self.list_data.append((self.edit_global_transpose, 0, f"Global Transpose ({display_val})", ["MIDI note transpose.\nThis effects all MIDI messages and is in addition to individual chain transpose.", "midi_settings.png"])) + if zynthian_gui_config.midi_chanpress_cc: + display_val = str(zynthian_gui_config.midi_chanpress_cc) + else: + display_val = "NONE" + self.list_data.append((self.set_chanpress_cc, 0, f"Channel Pressure => CC ({display_val})", + ["Map channel pressure messages (monoAT) to the selected CC.", + "midi_settings.png"])) + + self.list_data.append((None, 0, "> AUDIO")) + self.list_data.append((self.audio_levels, 0, "Audio Levels", ["Show audio levels view.", "meters.png"])) if self.state_manager.allow_rbpi_headphones(): if zynthian_gui_config.rbpi_headphones: self.list_data.append((self.stop_rbpi_headphones, 0, "\u2612 RBPi Headphones", @@ -382,6 +400,10 @@ def kill_command(self): # CONFIG OPTIONS # ------------------------------------------------------------------------------ + def audio_levels(self, t='S'): + logging.info("Audio Levels") + self.zyngui.show_screen("alsa_mixer") + def start_rbpi_headphones(self, save_config=True): logging.info("STARTING RBPI HEADPHONES") try: @@ -477,6 +499,16 @@ def toggle_dpm(self): zynthian_gui_config.enable_dpm = not zynthian_gui_config.enable_dpm self.update_list() + def visible_chains(self): + self.enable_param_editor(self, "Visible chains", + {'value_min': 4, 'value_max': 16, 'value': zynthian_gui_config.visible_mixer_strips}, + self.visible_chains_cb) + + def visible_launchers(self): + self.enable_param_editor(self, "Visible launchers", + {'value_min': 4, 'value_max': 16, 'value': zynthian_gui_config.visible_launchers}, + self.visible_launchers_cb) + def toggle_snapshot_mixer_settings(self): if zynthian_gui_config.snapshot_mixer_settings: logging.info("Mixer Settings on Snapshots OFF") @@ -525,6 +557,18 @@ def touch_navigation_cb_confirmed(self, value=""): zynconf.save_config({"ZYNTHIAN_UI_TOUCH_NAVIGATION2": value}) self.restart_gui() + def visible_chains_cb(self, value): + zynconf.save_config({"ZYNTHIAN_UI_VISIBLE_MIXER_STRIPS": str(value)}) + zynthian_gui_config.visible_mixer_strips = value + self.zyngui.screens["mixer"].update_layout() + self.update_list() + + def visible_launchers_cb(self, value): + zynconf.save_config({"ZYNTHIAN_UI_VISIBLE_LAUNCHERS": str(value)}) + zynthian_gui_config.visible_launchers = value + self.zyngui.screens["mixer"].update_layout() + self.update_list() + # ------------------------------------------------------------------------- # Global Transpose editing # ------------------------------------------------------------------------- @@ -533,6 +577,32 @@ def edit_global_transpose(self): self.enable_param_editor(self, "Global Transpose", {'value_min': -24, 'value_max': 24, 'value': lib_zyncore.get_global_transpose()}) + # ------------------------------------------------------------------------- + # Setting Channel Pressure => CC + # ------------------------------------------------------------------------- + + def set_chanpress_cc(self): + val = zynthian_gui_config.midi_chanpress_cc + if val is None: + val = 0 + self.enable_param_editor(self, "Channel Pressure => CC", + {'value_min': 0, 'value_max': 119, 'value': val}, + self.set_chanpress_cc_cb) + + def set_chanpress_cc_cb(self, value): + zynthian_gui_config.midi_chanpress_cc = value + self.state_manager.init_midi_filter() + + # Update Config + zynconf.update_midi_profile({ + "ZYNTHIAN_MIDI_CHANPRESS_CC": str(int(zynthian_gui_config.midi_chanpress_cc)) + }) + self.update_list() + + # ------------------------------------------------------------------------- + # Param Editor knob action + # ------------------------------------------------------------------------- + def send_controller_value(self, zctrl): """ Handle param editor value change """ diff --git a/zyngui/zynthian_gui_arranger.py b/zyngui/zynthian_gui_arranger.py index 34fff1782..641437c20 100644 --- a/zyngui/zynthian_gui_arranger.py +++ b/zyngui/zynthian_gui_arranger.py @@ -30,7 +30,6 @@ from time import monotonic, sleep from threading import Timer from math import sqrt -from collections import OrderedDict import os # Zynthian specific modules @@ -55,10 +54,6 @@ CELL_FOREGROUND = zynthian_gui_config.color_panel_tx GRID_LINE = zynthian_gui_config.color_tx -PLAY_MODES = ['Disabled', 'Oneshot', 'Loop', - 'Oneshot all', 'Loop all', 'Oneshot sync', 'Loop sync'] - - # Class implements step sequencer arranger class zynthian_gui_arranger(zynthian_gui_base.zynthian_gui_base): @@ -71,7 +66,7 @@ def __init__(self): self.ctrl_order = zynthian_gui_config.layout['ctrl_order'] - self.sequence_tracks = [] # Array of [Sequence,Track] that are visible within bank + self.sequence_tracks = [] # Array of [Sequence,Track] that are visible within scene self.sequence = 0 # Index of selected sequence self.track = 0 # Index of selected track @@ -163,7 +158,7 @@ def __init__(self): self.timebase_track_canvas.grid(column=1, row=1) # Local copy so we know if it has changed and grid needs redrawing - self.bank = self.zynseq.bank + self.scene = self.zynseq.scene self.update_sequence_tracks() # 0:No refresh, 1:Refresh cell, 2:Refresh row, 3:Refresh grid, 4: Redraw grid self.redraw_pending = 4 @@ -176,32 +171,34 @@ def setup_zynpots(self): # Function to add menus def show_menu(self): self.disable_param_editor() - options = OrderedDict() + options = {} options[f'Tempo ({self.zynseq.libseq.getTempo():0.1f})'] = 'Tempo' options['Beats per bar ({})'.format( - self.zyngui.state_manager.zynseq.libseq.getBeatsPerBar())] = 'Beats per bar' - options[f'Scene ({self.zynseq.bank})'] = 'Scene' + self.zyngui.state_manager.zynseq.bpb)] = 'Beats per bar' + options[f'Phrase ({self.zynseq.scene})'] = 'Phrase' options['> ARRANGER'] = None - if self.zynseq.libseq.isMuted(self.zynseq.bank, self.sequence, self.track): + if self.zynseq.libseq.isMuted(self.zynseq.scene, self.sequence, self.track): options['Unmute track'] = 'Unmute track' else: options['Mute track'] = 'Mute track' options['MIDI channel ({})'.format(1 + self.zynseq.libseq.getChannel( - self.zynseq.bank, self.sequence, self.track))] = 'MIDI channel' + self.zynseq.scene, self.sequence, self.track))] = 'MIDI channel' options['Vertical zoom ({})'.format( self.vertical_zoom)] = 'Vertical zoom' options['Horizontal zoom ({})'.format( self.horizontal_zoom)] = 'Horizontal zoom' options['Group ({})'.format(list(map(chr, range(65, 91)))[ - self.zynseq.libseq.getGroup(self.zynseq.bank, self.sequence)])] = 'Group' - options['Play mode ({})'.format(zynseq.PLAY_MODES[self.zynseq.libseq.getPlayMode( - self.zynseq.bank, self.sequence)])] = 'Play mode' + self.zynseq.libseq.getGroup(self.zynseq.scene, self.sequence)])] = 'Group' + options[f'Repeat ({self.zynseq.libseq.getRepeat(self.zynseq.scene, self.sequence)})'] = 'Repeat' + options[f'Follow action ({self.zynseq.libseq.getFollowAction(self.zynseq.scene, self.sequence)})'] = 'Follow' + options['Play mode ({})'.format(self.zynseq.libseq.getPlayMode( + self.zynseq.scene, self.sequence))] = 'Play mode' options['Pattern ({})'.format(self.pattern)] = 'Pattern' options['Add track'] = 'Add track' - if self.zynseq.libseq.getTracksInSequence(self.zynseq.bank, self.sequence) > 1: + if self.zynseq.libseq.getTracksInSequence(self.zynseq.scene, self.sequence) > 1: options['Remove track {}'.format(self.track + 1)] = 'Remove track' options['Clear sequence'] = 'Clear sequence' - options['Clear scene'] = 'Clear scene' + options['Clear Phrase'] = 'Clear phrase' options['Import SMF'] = 'Import SMF' self.zyngui.screens['option'].config( "Arranger Menu", options, self.menu_cb) @@ -210,7 +207,7 @@ def show_menu(self): def toggle_menu(self): if self.shown: self.show_menu() - elif self.zyngui.current_screen == "option": + elif self.zyngui.get_current_screen() == "option": self.close_screen() def menu_cb(self, option, params): @@ -218,10 +215,10 @@ def menu_cb(self, option, params): self.zyngui.show_screen('tempo') elif params == 'Beats per bar': self.enable_param_editor(self, 'bpb', {'name': 'Beats per bar', 'value_min': 1, - 'value_max': 64, 'value_default': 4, 'value': self.zynseq.libseq.getBeatsPerBar()}) - elif params == 'Scene': - self.enable_param_editor(self, 'scene', { - 'name': 'Scene', 'value_min': 1, 'value_max': 64, 'value': self.zynseq.bank}) + 'value_max': 64, 'value_default': 4, 'value': self.zynseq.bpb}) + elif params == 'Phrase': + self.enable_param_editor(self, 'phrase', { + 'name': 'Phrase', 'value_min': 1, 'value_max': 64, 'value': self.zynseq.scene}) elif 'ute track' in params: self.toggle_mute() elif params == 'MIDI channel': @@ -234,10 +231,10 @@ def menu_cb(self, option, params): else: labels.append(f"{midi_chan + 1}") self.enable_param_editor(self, 'midi_chan', {'name': 'MIDI channel', 'labels': labels, 'value_default': self.zynseq.libseq.getChannel( - self.zynseq.bank, self.sequence, self.track), 'value': self.zynseq.libseq.getChannel(self.zynseq.bank, self.sequence, self.track)}) + self.zynseq.scene, self.sequence, self.track), 'value': self.zynseq.libseq.getChannel(self.zynseq.scene, self.sequence, self.track)}) elif params == 'Play mode': self.enable_param_editor(self, 'playmode', {'name': 'Play mode', 'labels': zynseq.PLAY_MODES, 'value': self.zynseq.libseq.getPlayMode( - self.zynseq.bank, self.sequence), 'value_default': zynseq.SEQ_LOOPALL}) + self.zynseq.scene, self.sequence), 'value_default': zynseq.SEQ_LOOPALL}) elif params == 'Vertical zoom': self.enable_param_editor(self, 'vzoom', { 'name': 'Vertical zoom', 'value_min': 1, 'value_max': 127, 'value_default': 8, 'value': self.vertical_zoom}) @@ -246,7 +243,7 @@ def menu_cb(self, option, params): 'name': 'Horizontal zoom', 'value_min': 1, 'value_max': 64, 'value_default': 16, 'value': self.horizontal_zoom}) elif params == 'Group': self.enable_param_editor(self, 'group', {'name': 'Group', 'labels': list(map(chr, range(65, 91))), 'default': self.zynseq.libseq.getGroup( - self.zynseq.bank, self.sequence), 'value': self.zynseq.libseq.getGroup(self.zynseq.bank, self.sequence)}) + self.zynseq.scene, self.sequence), 'value': self.zynseq.libseq.getGroup(self.zynseq.scene, self.sequence)}) elif params == 'Pattern': self.enable_param_editor(self, 'pattern', { 'name': 'Pattern', 'value_min': 1, 'value_max': zynseq.SEQ_MAX_PATTERNS, 'value_default': self.pattern, 'value': self.pattern}) @@ -256,33 +253,32 @@ def menu_cb(self, option, params): self.remove_track() elif params == 'Clear sequence': self.clear_sequence() - elif params == 'Clear scene': + elif params == 'Clear phrase': self.zyngui.show_confirm( - f"Clear all sequences from scene {self.zynseq.bank} and reset to 4x4 grid of new sequences?\n\nThis will also remove all patterns and tracks from sequences in scene.", self.do_clear_bank) + f"Clear all sequences from phrase {self.zynseq.scene} and reset to 4x4 grid of new sequences?\n\nThis will also remove all patterns and tracks from sequences in phrase.", self.do_clear_scene) elif params == 'Import SMF': self.select_smf() def send_controller_value(self, zctrl): - if zctrl.symbol == 'scene': - self.zynseq.select_bank(zctrl.value) - # self.title = "Scene {}".format(self.zynseq.bank) - self.bank = self.zynseq.bank + if zctrl.symbol == 'phrase': + #self.zynseq.select_scene(zctrl.value) + self.scene = self.zynseq.scene + self.set_title(f"Phrase {self.scene}") self.update_sequence_tracks() self.redraw_pending = 4 - elif zctrl.symbol == 'tempo': - self.zynseq.libseq.setTempo(zctrl.value) if zctrl.symbol == 'metro_vol': self.zynseq.libseq.setMetronomeVolume(zctrl.value / 100.0) elif zctrl.symbol == 'bpb': - self.zynseq.set_beats_per_bar(zctrl.value) + self.zynseq.libseq.set_timesig(zctrl.value) self.draw_vertical_lines() elif zctrl.symbol == 'midi_chan': self.zynseq.set_midi_channel( - self.zynseq.bank, self.sequence, self.track, zctrl.value) + self.zynseq.scene, self.sequence, self.track, zctrl.value) self.draw_sequence_label(self.selected_cell[1] - self.row_offset) elif zctrl.symbol == 'playmode': + #TODO: Playmode has changed self.zynseq.set_play_mode( - self.zynseq.bank, self.sequence, zctrl.value) + self.zynseq.scene, self.sequence, zctrl.value) elif zctrl.symbol == 'vzoom': self.vertical_zoom = zctrl.value self.zynseq.libseq.setVerticalZoom(zctrl.value) @@ -293,7 +289,7 @@ def send_controller_value(self, zctrl): self.update_cell_size() self.redraw_pending = 3 elif zctrl.symbol == 'group': - self.zynseq.set_group(self.zynseq.bank, self.sequence, zctrl.value) + self.zynseq.set_group(self.zynseq.scene, self.sequence, zctrl.value) self.redraw_pending = 2 elif zctrl.symbol == 'pattern': self.set_pattern(zctrl.value) @@ -301,20 +297,20 @@ def send_controller_value(self, zctrl): # Function to toggle mute of selected track def toggle_mute(self, params=None): self.zynseq.libseq.toggleMute( - self.zynseq.bank, self.sequence, self.track) + self.zynseq.scene, self.sequence, self.track) self.redraw_pending = 2 - # Function to actually clear bank - def do_clear_bank(self, params=None): - self.zynseq.build_default_bank(self.zynseq.bank) - self.zynseq.select_bank(self.zynseq.bank, True) + # Function to actually clear scene + def do_clear_scene(self, params=None): + self.zynseq.build_default_scene(self.zynseq.scene) + self.zynseq.select_scene(self.zynseq.scene, True) self.update_sequence_tracks() - self.zynseq.libseq.setPlayPosition(self.zynseq.bank, self.sequence, 0) + self.zynseq.libseq.setSequencePlayPosition(self.zynseq.scene, self.sequence, 0) self.redraw_pending = 4 # Function to clear sequence def clear_sequence(self, params=None): - name = self.zynseq.get_sequence_name(self.zynseq.bank, self.sequence) + name = self.zynseq.get_sequence_name(self.zynseq.scene, self.sequence) if len(name) == 0: name = f"{self.sequence + 1}" self.zyngui.show_confirm( @@ -322,14 +318,14 @@ def clear_sequence(self, params=None): # Function to actually clear selected sequence def do_clear_sequence(self, params=None): - self.zynseq.libseq.clearSequence(self.zynseq.bank, self.sequence) + self.zynseq.libseq.clearSequence(self.zynseq.scene, self.sequence) self.update_sequence_tracks() self.redraw_pending = 4 # Function to add track to selected sequence immediately after selected track def add_track(self, params=None): self.zynseq.libseq.addTrackToSequence( - self.zynseq.bank, self.sequence, self.track) + self.zynseq.scene, self.sequence, self.track) self.update_sequence_tracks() self.redraw_pending = 4 @@ -341,7 +337,7 @@ def remove_track(self, params=None): # Function to actually remove selected track def do_remove_track(self, params=None): self.zynseq.libseq.removeTrackFromSequence( - self.zynseq.bank, self.sequence, self.track) + self.zynseq.scene, self.sequence, self.track) self.update_sequence_tracks() self.redraw_pending = 4 @@ -349,7 +345,7 @@ def do_remove_track(self, params=None): def select_smf(self, params=None): file_list = zynthian_engine.get_filelist(os.environ.get( 'ZYNTHIAN_MY_DATA_DIR', '/zynthian/zynthian-my-data') + '/capture', 'mid') - options = OrderedDict() + options = {} for i in file_list: options[i[4]] = i[0] self.zyngui.screens['option'].config( @@ -360,8 +356,8 @@ def select_smf(self, params=None): # fname: Filename # fpath: Full file path of SMF to import def smf_file_cb(self, fname, fpath): - # logging.warning(f"Seq len:{self.zynseq.libseq.getSequenceLength(self.zynseq.bank, self.sequence)} pos:{self.selected_cell[0]}") - if self.zynseq.libseq.getSequenceLength(self.zynseq.bank, self.sequence) > self.selected_cell[0] * 24: + # logging.warning(f"Seq len:{self.zynseq.libseq.getSequenceLength(self.zynseq.scene, self.sequence)} pos:{self.selected_cell[0]}") + if self.zynseq.libseq.getSequenceLength(self.zynseq.scene, self.sequence) > self.selected_cell[0] * 24: self.zyngui.show_confirm( "Import will overwrite part of existing sequence. Do you want to continue?", self.do_import_smf, fpath) else: @@ -383,25 +379,25 @@ def do_import_smf(self, fpath): progress = 0 progress_step = event_inc * 100 / event_count pattern_count = 0 - bank = self.zynseq.bank + scene = self.zynseq.scene sequence = self.sequence ticks_per_beat = zynsmf.libsmf.getTicksPerQuarterNote(smf) steps_per_beat = 24 ticks_per_step = ticks_per_beat / steps_per_beat - beats_in_pattern = self.zynseq.libseq.getBeatsPerBar() + beats_in_pattern = self.zynseq.timesig ticks_in_pattern = beats_in_pattern * ticks_per_beat clocks_per_step = 1 # For 24 steps per beat ticks_per_clock = ticks_per_step / clocks_per_step # Array of boolean flags indicating if track should be removed at end of import empty_tracks = [False for i in range(16)] - # self.zynseq.libseq.clearSequence(bank, sequence) # TODO Do not clear sequence, get sequence length, start at next bar position or current cursor position + # self.zynseq.libseq.clearSequence(scene, sequence) # TODO Do not clear sequence, get sequence length, start at next bar position or current cursor position # Add tracks to populate - we will delete unpopulated tracks at end for track in range(16): - if self.zynseq.libseq.getChannel(bank, sequence, track) != track: + if self.zynseq.libseq.getChannel(scene, sequence, track) != track: self.zynseq.libseq.addTrackToSequence( - bank, sequence, track - 1) - self.zynseq.libseq.setChannel(bank, sequence, track, track) + scene, sequence, track - 1) + self.zynseq.libseq.setChannel(scene, sequence, track, track) empty_tracks[track] = True # Do import @@ -439,12 +435,12 @@ def do_import_smf(self, fpath): pattern_position[channel] += ticks_in_pattern pattern[channel] = self.zynseq.libseq.createPattern() self.zynseq.libseq.selectPattern(pattern[channel]) - self.zynseq.libseq.setBeatsInPattern(beats_in_pattern) + self.zynseq.libseq.setBeatsInPattern(pattern[channel], beats_in_pattern) self.zynseq.libseq.setStepsPerBeat(steps_per_beat) position = int( pattern_position[channel] / ticks_per_clock) self.zynseq.libseq.addPattern( - bank, sequence, channel, position, pattern[channel], True) + scene, sequence, channel, position, pattern[channel], True) pattern_count += 1 step = int( (time - pattern_position[channel]) / ticks_per_step) @@ -481,7 +477,7 @@ def do_import_smf(self, fpath): for track in range(15, -1, -1): if empty_tracks[track]: self.zynseq.libseq.removeTrackFromSequence( - bank, sequence, track) + scene, sequence, track) zynsmf.libsmf.removeSmf(smf) self.update_sequence_tracks() @@ -496,14 +492,14 @@ def update_layout(self): # Function to show GUI def build_view(self): + self.scene = self.zynseq.scene self.vertical_zoom = self.zynseq.libseq.getVerticalZoom() self.horizontal_zoom = self.zynseq.libseq.getHorizontalZoom() self.setup_zynpots() if not self.param_editor_zctrl: - self.set_title(f"Scene {self.zynseq.bank}") + self.set_title(f"Phrase {self.zynseq.scene}") self.redraw_pending = 3 - self.bank = self.zynseq.bank - self.title = f"Scene {self.bank}" + self.title = f"Phrase {self.scene}" self.update_sequence_tracks() self.redraw_pending = 4 self.select_position() @@ -526,17 +522,17 @@ def set_pattern(self, pattern): self.pattern_to_add = self.pattern self.select_cell() - # Function to get quantity of sequences in bank - # returns: Quantity of sequences in bank + # Function to get quantity of sequences in scene + # returns: Quantity of sequences in scene def get_seqeuences(self): - return self.zynseq.libseq.getSequencesInBank(self.zynseq.bank) + return self.zynseq.libseq.getSequencesInscene(self.zynseq.scene) # Function to handle start of sequence drag def on_sequence_drag_start(self, event): if self.param_editor_zctrl: self.disable_param_editor() return - if self.zynseq.libseq.getSequencesInBank(self.zynseq.bank) > self.vertical_zoom: + if self.zynseq.libseq.getSequencesInscene(self.zynseq.scene) > self.vertical_zoom: self.sequence_drag_start = event # Function to handle sequence drag motion @@ -550,9 +546,9 @@ def on_sequence_drag_motion(self, event): pos = self.row_offset - offset if pos < 0: pos = 0 - if pos + self.vertical_zoom >= self.zynseq.libseq.getSequencesInBank(self.zynseq.bank): - pos = self.zynseq.libseq.getSequencesInBank( - self.zynseq.bank) - self.vertical_zoom + if pos + self.vertical_zoom >= self.zynseq.libseq.getSequencesInscene(self.zynseq.scene): + pos = self.zynseq.libseq.getSequencesInscene( + self.zynseq.scene) - self.vertical_zoom if self.row_offset == pos: return self.row_offset = pos @@ -573,7 +569,7 @@ def on_seq_mouse_scroll(self, event): if event.num == 4: # Scroll up # TODO: Need to validate vertical range of tracks, not sequences - if self.row_offset + self.vertical_zoom < self.zynseq.libseq.getSequencesInBank(self.zynseq.bank): + if self.row_offset + self.vertical_zoom < self.zynseq.libseq.getSequencesInscene(self.zynseq.scene): self.row_offset += 1 if self.selected_cell[1] < self.row_offset: self.select_cell(self.selected_cell[0], self.row_offset) @@ -645,7 +641,7 @@ def on_grid_press(self, event): self.source_seq = self.sequence self.source_track = self.track self.pattern_to_add = self.zynseq.libseq.getPattern( - self.zynseq.bank, self.sequence, self.track, self.source_col * self.clocks_per_division) + self.zynseq.scene, self.sequence, self.track, self.source_col * self.clocks_per_division) if self.pattern_to_add == -1: self.pattern_to_add = self.pattern @@ -681,7 +677,7 @@ def on_grid_drag(self, event): self.grid_timer.cancel() time = self.selected_cell[0] * self.clocks_per_division self.pattern_to_add = self.zynseq.libseq.getPattern( - self.zynseq.bank, self.sequence, self.track, time) + self.zynseq.scene, self.sequence, self.track, time) if self.pattern_to_add == -1: self.pattern_to_add = self.pattern if col != self.selected_cell[0] or row != self.selected_cell[1]: @@ -698,14 +694,14 @@ def show_pattern_editor(self): # time in clock cycles time = self.selected_cell[0] * self.clocks_per_division pattern = self.zynseq.libseq.getPattern( - self.zynseq.bank, self.sequence, self.track, time) + self.zynseq.scene, self.sequence, self.track, time) channel = self.zynseq.libseq.getChannel( - self.zynseq.bank, self.sequence, self.track) + self.zynseq.scene, self.sequence, self.track) if pattern > 0: self.zyngui.screens['pattern_editor'].channel = channel self.zyngui.screens['pattern_editor'].load_pattern(pattern) self.zynseq.libseq.enableMidiRecord(False) - self.zyngui.screens['pattern_editor'].bank = 0 + self.zyngui.screens['pattern_editor'].scene = 0 self.zyngui.screens['pattern_editor'].sequence = 0 self.zynseq.libseq.enableMidiRecord(False) self.zyngui.show_screen("pattern_editor") @@ -719,19 +715,19 @@ def on_pattern_click(self, event): # Toggle playback of selected sequence def toggle_play(self): - # if self.zynseq.libseq.getPlayState(self.zynseq.bank, self.sequence) == zynseq.SEQ_STOPPED: - # bars = int(self.selected_cell[0] / self.zynseq.libseq.getBeatsPerBar()) - # pos = bars * self.zynseq.libseq.getBeatsPerBar() * self.clocks_per_division - # if self.zynseq.libseq.getSequenceLength(self.zynseq.bank, self.sequence) > pos: - # self.zynseq.libseq.setPlayPosition(self.zynseq.bank, self.sequence, pos) - self.zynseq.libseq.togglePlayState(self.zynseq.bank, self.sequence) + # if self.zynseq.libseq.getPlayState(self.zynseq.scene, self.sequence) == zynseq.SEQ_STOPPED: + # bars = int(self.selected_cell[0] / self.zynseq.timesig) + # pos = bars * self.zynseq.timesig * self.clocks_per_division + # if self.zynseq.libseq.getSequenceLength(self.zynseq.scene, self.sequence) > pos: + # self.zynseq.libseq.setSequencePlayPosition(self.zynseq.scene, self.sequence, pos) + self.zynseq.libseq.togglePlayState(self.zynseq.scene, self.sequence) # Function to toggle note event # col: Grid column relative to start of song # row: Grid row def toggle_event(self, col, row): time = col * self.clocks_per_division - if self.zynseq.libseq.getPattern(self.zynseq.bank, self.sequence, self.track, time) == -1: + if self.zynseq.libseq.getPattern(self.zynseq.scene, self.sequence, self.track, time) == -1: self.add_event(col, self.sequence, self.track) else: self.remove_event(col, self.sequence, self.track) @@ -743,7 +739,7 @@ def toggle_event(self, col, row): # track: Track within sequence def remove_event(self, col, sequence, track): time = col * self.clocks_per_division - self.zynseq.remove_pattern(self.zynseq.bank, sequence, track, time) + self.zynseq.remove_pattern(self.zynseq.scene, sequence, track, time) self.redraw_pending = 2 # Function to add an event @@ -753,7 +749,7 @@ def remove_event(self, col, sequence, track): # returns: True on success def add_event(self, col, sequence, track): time = col * self.clocks_per_division - if self.zynseq.add_pattern(self.zynseq.bank, sequence, track, time, self.pattern_to_add): + if self.zynseq.add_pattern(self.zynseq.scene, sequence, track, time, self.pattern_to_add): self.redraw_pending = 2 return True return False @@ -770,12 +766,12 @@ def draw_sequence_label(self, row): return sequence = self.sequence_tracks[row + self.row_offset][0] track = self.sequence_tracks[row + self.row_offset][1] - group = self.zynseq.libseq.getGroup(self.zynseq.bank, sequence) - fill = zynthian_gui_config.PAD_COLOUR_GROUP_LIGHT[group % 16] + group = self.zynseq.libseq.getGroup(self.zynseq.scene, sequence) + fill = zynthian_gui_config.LAUNCHER_COLOUR[group % 16]["rgb_light"] font = tkfont.Font( family=zynthian_gui_config.font_topbar[0], size=self.fontsize) midi_chan = self.zynseq.libseq.getChannel( - self.zynseq.bank, sequence, track) + self.zynseq.scene, sequence, track) track_name = self.zyngui.chain_manager.get_synth_preset_name(midi_chan) self.sequence_title_canvas.create_rectangle(0, self.row_height * row + 1, @@ -785,7 +781,7 @@ def draw_sequence_label(self, row): # Create sequence title label from first visible track of sequence self.sequence_title_canvas.create_text((0, self.row_height * row + 1), font=font, fill=CELL_FOREGROUND, tags=(f"rowtitle:{row}", "sequence_title"), anchor="nw", - text=self.zynseq.get_sequence_name(self.zynseq.bank, sequence)) + text=self.zynseq.get_sequence_name(self.zynseq.scene, sequence)) self.grid_canvas.delete(f"playheadline-{row}") self.grid_canvas.create_line(0, self.row_height * (row + 1), 0, self.row_height * row, fill=PLAYHEAD_CURSOR, tags=("playheadline", f"playheadline-{row}"), state='hidden') @@ -853,18 +849,18 @@ def draw_cell(self, col, row): self.clocks_per_division # time in clock cycles pattern = self.zynseq.libseq.getPattern( - self.zynseq.bank, sequence, track, time) + self.zynseq.scene, sequence, track, time) if pattern == -1 and col == 0: # Search for earlier pattern that extends into view pattern = self.zynseq.libseq.getPatternAt( - self.zynseq.bank, sequence, track, time) + self.zynseq.scene, sequence, track, time) if pattern != -1: duration = int(self.zynseq.libseq.getPatternLength( pattern) / self.clocks_per_division) while time > 0 and duration > 1: time -= self.clocks_per_division duration -= 1 - if pattern == self.zynseq.libseq.getPattern(self.zynseq.bank, sequence, track, time): + if pattern == self.zynseq.libseq.getPattern(self.zynseq.scene, sequence, track, time): break elif pattern != -1: duration = int(self.zynseq.libseq.getPatternLength( @@ -872,7 +868,7 @@ def draw_cell(self, col, row): if pattern == -1: duration = 1 fill = CANVAS_BACKGROUND - elif self.zynseq.libseq.isMuted(self.zynseq.bank, sequence, track): + elif self.zynseq.libseq.isMuted(self.zynseq.scene, sequence, track): fill = zynthian_gui_config.PAD_COLOUR_DISABLED else: fill = CELL_BACKGROUND @@ -962,13 +958,13 @@ def draw_grid(self): self.timebase_track_canvas.delete('bpm') ''' #TODO: Implement timebase events - not yet implemented in library - for event in range(self.zynseq.libseq.getTimebaseEvents(self.zynseq.bank)): - time = self.zynseq.libseq.getTimebaseEventTime(self.zynseq.bank, event) / self.clocks_per_division + for event in range(self.zynseq.libseq.getTimebaseEvents(self.zynseq.scene)): + time = self.zynseq.libseq.getTimebaseEventTime(self.zynseq.scene, event) / self.clocks_per_division if time >= self.col_offset and time <= self.col_offset + self.horizontal_zoom: - command = self.zynseq.libseq.getTimebaseEventCommand(self.zynseq.bank, event) + command = self.zynseq.libseq.getTimebaseEventCommand(self.zynseq.scene, event) if command == 1: # Tempo tempoX = (time - self.col_offset) * self.column_width - data = self.zynseq.libseq.getTimebaseEventData(self.zynseq.bank, event) + data = self.zynseq.libseq.getTimebaseEventData(self.zynseq.scene, event) if tempoX: self.timebase_track_canvas.create_text(tempoX, tempo_y, fill='red', text=data, anchor='n', tags='bpm') else: @@ -984,7 +980,7 @@ def draw_vertical_lines(self): font = tkfont.Font(size=self.small_font_size) tempo_y = font.metrics('linespace') offset = 0 - int(self.col_offset % self.horizontal_zoom) - for bar in range(offset, self.horizontal_zoom, self.zynseq.libseq.getBeatsPerBar()): + for bar in range(offset, self.horizontal_zoom, self.zynseq.timesig): self.grid_canvas.create_line( bar * self.column_width, 0, bar * self.column_width, self.grid_height, fill='#808080', tags='barlines') if bar: @@ -1048,7 +1044,7 @@ def select_cell(self, time=None, row=None, snap=True, scroll=True): for previous in range(time - 1, -1, -1): # Iterate time divs back to start prev_pattern = self.zynseq.libseq.getPattern( - self.zynseq.bank, sequence, track, previous * self.clocks_per_division) + self.zynseq.scene, sequence, track, previous * self.clocks_per_division) if prev_pattern == -1: continue prev_duration = int(self.zynseq.libseq.getPatternLength( @@ -1058,7 +1054,7 @@ def select_cell(self, time=None, row=None, snap=True, scroll=True): break for nxt in range(time + 1, time + duration * 2): next_pattern = self.zynseq.libseq.getPattern( - self.zynseq.bank, sequence, track, nxt * self.clocks_per_division) + self.zynseq.scene, sequence, track, nxt * self.clocks_per_division) if next_pattern == -1: continue next_start = nxt @@ -1159,13 +1155,15 @@ def get_note(self, note): return f"{note_name}{octave}" # Function to update array of sequences, tracks - # Returns: Quanity of tracks in bank + # Returns: Quanity of tracks in scene def update_sequence_tracks(self): old_tracks = self.sequence_tracks.copy() self.sequence_tracks.clear() - for sequence in range(self.zynseq.libseq.getSequencesInBank(self.zynseq.bank)): - for track in range(self.zynseq.libseq.getTracksInSequence(self.zynseq.bank, sequence)): + """ TODO: fix + for sequence in range(self.zynseq.libseq.getSequencesInscene(self.zynseq.scene)): + for track in range(self.zynseq.libseq.getTracksInSequence(self.zynseq.scene, sequence)): self.sequence_tracks.append((sequence, track)) + """ if old_tracks != self.sequence_tracks: self.redraw_pending = 4 return len(self.sequence_tracks) @@ -1190,7 +1188,7 @@ def refresh_status(self): self.grid_canvas.itemconfig( f"playheadline-{seq_row}", state="normal") pos = self.zynseq.libseq.getPlayPosition( - self.zynseq.bank, sequence) / self.clocks_per_division + self.zynseq.scene, sequence) / self.clocks_per_division x = (pos - self.col_offset) * self.column_width if sequence == previous_sequence: y2 = self.row_height * (row + 1) @@ -1199,7 +1197,7 @@ def refresh_status(self): y2 = self.row_height * (row + 1) seq_row = row previous_sequence = sequence - if sequence == self.sequence and self.zynseq.libseq.getPlayState(self.zynseq.bank, sequence) in [zynseq.SEQ_PLAYING, zynseq.SEQ_STOPPING]: + if sequence == self.sequence and self.zynseq.libseq.getPlayState(self.zynseq.scene, sequence) in [zynseq.SEQ_PLAYING, zynseq.SEQ_STOPPING]: if x > self.grid_width: self.select_cell(int(pos), self.selected_cell[1]) elif x < 0: @@ -1215,7 +1213,6 @@ def zynpot_cb(self, i, dval): return if i == self.ctrl_order[0] and zynthian_gui_config.transport_clock_source <= 1: # Tempo change - self.zynseq.update_tempo() self.zynseq.nudge_tempo(dval) self.set_title("Tempo: {:.1f}".format( self.zynseq.get_tempo()), None, None, 2) @@ -1251,10 +1248,10 @@ def switch(self, i, t): elif i == 2: if t == 'S': self.zynseq.libseq.togglePlayState( - self.zynseq.bank, self.sequence) + self.zynseq.scene, self.sequence) elif t == 'B': - self.zynseq.libseq.setPlayPosition( - self.zynseq.bank, self.sequence, 0) + self.zynseq.libseq.setSequencePlayPosition( + self.zynseq.scene, self.sequence, 0) else: return False return True @@ -1284,14 +1281,14 @@ def arrow_down(self): def start_playback(self): self.zynseq.libseq.setPlayState( - self.bank, self.sequence, zynseq.SEQ_STARTING) + self.scene, self.sequence, zynseq.SEQ_STARTING) def stop_playback(self): self.zynseq.libseq.setPlayState( - self.bank, self.sequence, zynseq.SEQ_STOPPED) + self.scene, self.sequence, zynseq.SEQ_STOPPED) def toggle_playback(self): - if self.zynseq.libseq.getPlayState(self.bank, self.sequence) == zynseq.SEQ_STOPPED: + if self.zynseq.libseq.getPlayState(self.scene, self.sequence) == zynseq.SEQ_STOPPED: self.start_playback() else: self.stop_playback() diff --git a/zyngui/zynthian_gui_audio_in.py b/zyngui/zynthian_gui_audio_in.py index 9b35a2acf..81dd9fcb4 100644 --- a/zyngui/zynthian_gui_audio_in.py +++ b/zyngui/zynthian_gui_audio_in.py @@ -38,12 +38,8 @@ class zynthian_gui_audio_in(zynthian_gui_selector_info): def __init__(self): - self.chain = None super().__init__('Audio In') - def set_chain(self, chain): - self.chain = chain - def build_view(self): self.check_ports = 0 self.capture_ports = zynautoconnect.get_audio_capture_ports() @@ -67,7 +63,7 @@ def fill_list(self): suffix = f" ({scp.aliases[0]})" else: suffix = "" - if i + 1 in self.chain.audio_in: + if i + 1 in self.zyngui.chain_manager.active_chain.audio_in: self.list_data.append( (i + 1, scp.name, f"\u2612 Audio input {i + 1}{suffix}", [f"Audio input {i + 1} is connected to this chain.", "audio_input.png"])) @@ -83,7 +79,7 @@ def fill_listbox(self): def select_action(self, i, t='S'): if t == 'S': - self.chain.toggle_audio_in(self.list_data[i][0]) + self.zyngui.chain_manager.active_chain.toggle_audio_in(self.list_data[i][0]) self.fill_list() elif t == "B": if not self.list_data[i][1].startswith("system:"): diff --git a/zyngui/zynthian_gui_audio_out.py b/zyngui/zynthian_gui_audio_out.py index 2f1cd5591..780a24b23 100644 --- a/zyngui/zynthian_gui_audio_out.py +++ b/zyngui/zynthian_gui_audio_out.py @@ -40,31 +40,12 @@ class zynthian_gui_audio_out(zynthian_gui_selector_info): def __init__(self): - self.chain = None super().__init__('Audio Out') def build_view(self): self.check_ports = 0 self.playback_ports = zynautoconnect.get_hw_audio_dst_ports() - if super().build_view(): - zynsigman.register_queued( - zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_STATE, self.update_rec) - return True - else: - return False - - def hide(self): - if self.shown: - zynsigman.unregister( - zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_STATE, self.update_rec) - super().hide() - - def update_rec(self, state): - # Lock multitrack record config when recorder is recording - self.fill_list() - - def set_chain(self, chain): - self.chain = chain + return super().build_view() def refresh_status(self): super().refresh_status() @@ -78,13 +59,13 @@ def refresh_status(self): def fill_list(self): self.list_data = [] - if self.chain.chain_id: + if self.zyngui.chain_manager.active_chain.chain_id: # Normal chain so add mixer / chain targets port_names = [("Main mixbus", 0, ["Send audio from this chain to the main mixbus", "audio_output.png"])] self.list_data.append((None, None, "> Chain inputs")) for chain_id, chain in self.zyngui.chain_manager.chains.items(): - if chain_id != 0 and chain != self.chain and chain.audio_thru or chain.is_synth() and chain.synth_slots[0][0].type == "Special": - if self.zyngui.chain_manager.will_audio_howl(self.chain.chain_id, chain_id): + if chain_id != 0 and chain != self.zyngui.chain_manager.active_chain and chain.audio_thru or chain.is_synth() and chain.synth_slots[0][0].type == "Special": + if self.zyngui.chain_manager.will_audio_howl(self.zyngui.chain_manager.active_chain.chain_id, chain_id): prefix = "∞ " else: prefix = "" @@ -98,12 +79,12 @@ def fill_list(self): logging.info(e) pass for title, processor, info in port_names: - if processor in self.chain.audio_out: + if processor in self.zyngui.chain_manager.active_chain.audio_out: self.list_data.append((processor, processor, "\u2612 " + title, info)) else: self.list_data.append((processor, processor, "\u2610 " + title, info)) - if self.chain.is_audio(): + if self.zyngui.chain_manager.active_chain.is_audio(): port_names = [] # Direct physical outputs self.list_data.append((None, None, "> Direct Outputs")) @@ -122,31 +103,16 @@ def fill_list(self): port_names.append((f"Output {i + 2}{suffix}", f"^{self.playback_ports[i + 1].name}$", [f"Send audio from this chain directly to physical audio output {i + 2} as mono.", "audio_output.png"])) port_names.append((f"Outputs {i + 1}+{i + 2} (stereo)", f"^{self.playback_ports[i].name}$|^{self.playback_ports[i + 1].name}$", [f"Send audio from this chain directly to physical audio outputs {i + 1} & {i + 2} as stereo.", "audio_output.png"])) for title, processor, info in port_names: - if processor in self.chain.audio_out: + if processor in self.zyngui.chain_manager.active_chain.audio_out: self.list_data.append((processor, processor, "\u2612 " + title, info)) else: self.list_data.append((processor, processor, "\u2610 " + title, info)) - self.list_data.append((None, None, "> Audio Recorder")) - armed = self.zyngui.state_manager.audio_recorder.is_armed(self.chain.mixer_chan) - if self.zyngui.state_manager.audio_recorder.status: - locked = None - else: - locked = "record" - if armed: - self.list_data.append((locked, 'record_disable', '\u2612 Record chain', [f"The chain will be recorded as a stereo track within a multitrack audio recording.", "audio_output.png"])) - else: - self.list_data.append((locked, 'record_enable', '\u2610 Record chain', [f"The chain will be not be recorded as a stereo track within a multitrack audio recording.", "audio_output.png"])) - super().fill_list() def select_action(self, i, t='S'): - if self.list_data[i][0] == 'record': - if t == 'S': - self.zyngui.state_manager.audio_recorder.toggle_arm(self.chain.mixer_chan) - self.fill_list() - elif t == 'S': - self.chain.toggle_audio_out(self.list_data[i][0]) + if t == 'S': + self.zyngui.chain_manager.active_chain.toggle_audio_out(self.list_data[i][0]) self.fill_list() elif t == "B": if not self.list_data[i][1].startswith("^system:"): diff --git a/zyngui/zynthian_gui_bank.py b/zyngui/zynthian_gui_bank.py index 86e1abe33..ef2883d59 100644 --- a/zyngui/zynthian_gui_bank.py +++ b/zyngui/zynthian_gui_bank.py @@ -142,7 +142,7 @@ def show_menu(self): def toggle_menu(self): if self.shown: self.show_menu() - elif self.zyngui.current_screen == "option": + elif self.zyngui.get_current_screen() == "option": self.close_screen() def bank_options_cb(self, option, bank): diff --git a/zyngui/zynthian_gui_base.py b/zyngui/zynthian_gui_base.py index 3256f1f5a..afe64d036 100644 --- a/zyngui/zynthian_gui_base.py +++ b/zyngui/zynthian_gui_base.py @@ -57,6 +57,8 @@ def __init__(self, has_backbutton=True): self.columnconfigure(0, weight=1) self.shown = False self.zyngui = zynthian_gui_config.zyngui + self.state_manager = self.zyngui.state_manager + self.chain_manager = self.zyngui.chain_manager self.topbar_allowed = True self.topbar_height = zynthian_gui_config.topbar_height @@ -156,6 +158,7 @@ def __init__(self, has_backbutton=True): self.topbar_timer = None self.title_timer = None self.status_timer = None + self.set_title_ts = 0 col += 1 # Topbar's Select Path @@ -188,6 +191,7 @@ def __init__(self, has_backbutton=True): # Topbar parameter editor self.param_editor_zctrl = None + self.param_editor_assert_cb = None # Main Frame self.main_frame = tkinter.Frame(self, bg=zynthian_gui_config.color_bg) @@ -210,10 +214,10 @@ def __init__(self, has_backbutton=True): self.set_select_path() self.cb_scroll_select_path() - # TODO: Consolidate set_title and set_select_path, etc. - self.disable_param_editor() self.bind("", self.on_size) + # TODO: Consolidate set_title and set_select_path, etc. + def show_back_button(self, show=True): if show: self.backbutton_canvas.grid(row=0, column=0, sticky="wn", padx=(0, self.status_lpad)) @@ -228,6 +232,12 @@ def show_back_button(self, show=True): # timeout: If set, title is shown for this period (seconds) then reverts to previous title def set_title(self, title, fg=None, bg=None, timeout=None): + # Limit title update rate (30fps) + ts = time.monotonic() + if ts -self.set_title_ts < 0.0333: + return + self.set_title_ts = ts + if self.title_timer: self.title_timer.cancel() self.title_timer = None @@ -403,7 +413,7 @@ def topbar_short_touch_action(self): # Default topbar bold touch action def topbar_bold_touch_action(self): - self.zyngui.callable_ui_action('screen_zynpad') + self.zyngui.callable_ui_action('screen_launcher') # Default topbar long touch action def topbar_long_touch_action(self): @@ -438,7 +448,7 @@ def status_short_touch_action(self): # Default status bold touch action def status_bold_touch_action(self): - if self.zyngui.current_screen == 'zs3': + if self.zyngui.get_current_screen() == 'zs3': self.zyngui.callable_ui_action('screen_snapshot') else: self.zyngui.callable_ui_action('screen_zs3') @@ -630,50 +640,53 @@ def init_status(self): state=tkinter.HIDDEN) def init_dpmeter(self): - last_chan = self.zyngui.state_manager.zynmixer.MAX_NUM_CHANNELS - 1 width = int(self.status_l - 2 * self.status_rh - 1) height = int(self.status_h / 4 - 2) - self.dpm_a = zynthian_gui_dpm(self.zyngui.state_manager.zynmixer, last_chan, 0, self.status_canvas, - 0, 0, width, height, False, ("status_dpm")) - self.dpm_b = zynthian_gui_dpm(self.zyngui.state_manager.zynmixer, last_chan, 1, self.status_canvas, - 0, height + 2, width, height, False, ("status_dpm")) + self.dpm_a = zynthian_gui_dpm(self.status_canvas, 0, 0, width, height, False, ("status_dpm")) + self.dpm_b = zynthian_gui_dpm(self.status_canvas, 0, height + 2, width, height, False, ("status_dpm")) def refresh_status(self): if self.shown: - last_chan = self.zyngui.state_manager.zynmixer.MAX_NUM_CHANNELS - 1 - mute = self.zyngui.state_manager.zynmixer.get_mute(last_chan) - if True: # mute != self.main_mute: + mute = self.state_manager.zynmixer_bus.get_mute(0) + if mute != self.main_mute: self.main_mute = mute if mute: - self.status_canvas.itemconfigure(self.status_mute, state=tkinter.NORMAL) - if self.dpm_a: - self.status_canvas.itemconfigure('status_dpm', state=tkinter.HIDDEN) + self.status_canvas.itemconfigure( + self.status_mute, state=tkinter.NORMAL) + self.status_canvas.itemconfigure( + 'status_dpm', state=tkinter.HIDDEN) else: - self.status_canvas.itemconfigure(self.status_mute, state=tkinter.HIDDEN) - if self.dpm_a: - self.status_canvas.itemconfigure('status_dpm', state=tkinter.NORMAL) + self.status_canvas.itemconfigure( + self.status_mute, state=tkinter.HIDDEN) + self.status_canvas.itemconfigure( + 'status_dpm', state=tkinter.NORMAL) if not mute and self.dpm_a: - state = self.zyngui.state_manager.zynmixer.get_dpm_states(last_chan, last_chan)[0] - self.dpm_a.refresh(state[0], state[2], state[4]) - self.dpm_b.refresh(state[1], state[3], state[4]) + self.state_manager.zynmixer_bus.update_dpm_states(1) + dpm = self.state_manager.zynmixer_bus.dpm[0] + self.dpm_a.refresh(dpm.a, dpm.a_hold, dpm.mono) + self.dpm_b.refresh(dpm.b, dpm.b_hold, dpm.mono) # status['xrun'] = True; # Display error flags flags = "" color = zynthian_gui_config.color_status_error - if self.zyngui.state_manager.status_xrun: + if self.state_manager.status_xrun == 2: color = zynthian_gui_config.color_status_error # flags = "\uf00d" flags = "\uf071" - elif self.zyngui.state_manager.status_undervoltage: + elif self.state_manager.status_xrun == 1: + color = zynthian_gui_config.color_status_warn + # flags = "\uf00d" + flags = "\uf071" + elif self.state_manager.status_undervoltage: flags = "\uf0e7" - elif self.zyngui.state_manager.status_overtemp: + elif self.state_manager.status_overtemp: color = zynthian_gui_config.color_status_error # flags = "\uf2c7" flags = "\uf769" else: - cpu_load = self.zyngui.state_manager.status_cpu_load + cpu_load = self.state_manager.status_cpu_load if cpu_load < 50: cr = 0 cg = 0xCC @@ -684,7 +697,7 @@ def refresh_status(self): cr = 0xCC cg = int((100 - cpu_load) * 0xCC / 25) color = "#%02x%02x%02x" % (cr, cg, 0) - if self.zyngui.state_manager.update_available: + if self.state_manager.update_available: flags = "\u21bb" else: flags = "\u2665" @@ -695,7 +708,7 @@ def refresh_status(self): # Display Audio Rec flag flags = "" color = zynthian_gui_config.color_bg - if self.zyngui.state_manager.audio_recorder.status: + if self.state_manager.audio_recorder.status: self.status_canvas.itemconfig( self.status_audio_rec, state=tkinter.NORMAL) else: @@ -705,7 +718,7 @@ def refresh_status(self): # Display Audio Play flag flags = "" color = zynthian_gui_config.color_bg - if self.zyngui.state_manager.status_audio_player: + if self.state_manager.status_audio_player: self.status_canvas.itemconfig( self.status_audio_play, state=tkinter.NORMAL) else: @@ -715,7 +728,7 @@ def refresh_status(self): # Display MIDI Rec flag flags = "" color = zynthian_gui_config.color_status_midi - if self.zyngui.state_manager.status_midi_recorder: + if self.state_manager.status_midi_recorder: self.status_canvas.itemconfig( self.status_midi_rec, state=tkinter.NORMAL) else: @@ -723,14 +736,14 @@ def refresh_status(self): self.status_midi_rec, state=tkinter.HIDDEN) # Display MIDI Play flag - if self.zyngui.state_manager.status_midi_player: + if self.state_manager.status_midi_player: self.status_canvas.itemconfig( self.status_midi_play, state=tkinter.NORMAL) else: self.status_canvas.itemconfig( self.status_midi_play, state=tkinter.HIDDEN) # Display SEQ Rec flag - if self.zyngui.state_manager.zynseq.libseq.isMidiRecord(): + if self.state_manager.zynseq.libseq.isMidiRecord(): self.status_canvas.itemconfig( self.status_seq_rec, state=tkinter.NORMAL) else: @@ -738,7 +751,7 @@ def refresh_status(self): self.status_seq_rec, state=tkinter.HIDDEN) # Display SEQ Play flag - if self.zyngui.state_manager.zynseq.libseq.getPlayingSequences() > 0: + if self.state_manager.zynseq.playing_sequences > 0: self.status_canvas.itemconfig( self.status_seq_play, state=tkinter.NORMAL) else: @@ -746,7 +759,7 @@ def refresh_status(self): self.status_seq_play, state=tkinter.HIDDEN) # Display MIDI activity flag - if self.zyngui.state_manager.status_midi: + if self.state_manager.status_midi: self.status_canvas.itemconfig( self.status_midi, state=tkinter.NORMAL) else: @@ -754,7 +767,7 @@ def refresh_status(self): self.status_midi, state=tkinter.HIDDEN) # Display MIDI clock flag - if self.zyngui.state_manager.status_midi_clock: + if self.state_manager.status_midi_clock: self.status_canvas.itemconfig( self.status_midi_clock, state=tkinter.NORMAL) else: @@ -801,10 +814,9 @@ def switch_select(self, typ='S'): self.disable_param_editor() return True elif typ == 'B': - self.param_editor_zctrl.set_value( - self.param_editor_zctrl.value_default) - self.update_param_editor() - return True + self.param_editor_zctrl.set_value(self.param_editor_zctrl.value_default) + self.update_param_editor() + return True def back_action(self): if self.param_editor_zctrl: @@ -892,8 +904,7 @@ def enable_param_editor(self, engine, symbol, options, assert_cb=None): else: self.format_print = "{}: {}" - self.label_select_path.config( - bg=zynthian_gui_config.color_panel_tx, fg=zynthian_gui_config.color_header_bg) + self.label_select_path.config(bg=zynthian_gui_config.color_panel_tx, fg=zynthian_gui_config.color_header_bg) self.init_buttonbar([("ZYNPOT 3,-1", "-1"), ("ZYNPOT 3,+1", "+1"), ("ZYNPOT 3,-10", "-10"), ("ZYNPOT 3,+10", "+10"), (3, "OK")]) self.update_param_editor() @@ -917,10 +928,8 @@ def disable_param_editor(self): def update_param_editor(self): if self.param_editor_zctrl: if self.param_editor_zctrl.labels: - self.select_path.set("{}: {}".format( - self.param_editor_zctrl.name, self.param_editor_zctrl.get_value2label())) + self.select_path.set(f"{self.param_editor_zctrl.name}: {self.param_editor_zctrl.get_value2label()}") else: - self.select_path.set(self.format_print.format( - self.param_editor_zctrl.name, self.param_editor_zctrl.value)) + self.select_path.set(self.format_print.format(self.param_editor_zctrl.name, self.param_editor_zctrl.value)) # ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_gui_chain_manager.py b/zyngui/zynthian_gui_chain_manager.py new file mode 100644 index 000000000..5270bece7 --- /dev/null +++ b/zyngui/zynthian_gui_chain_manager.py @@ -0,0 +1,999 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian GUI +# +# Zynthian GUI Chain View Class +# +# Copyright (C) 2025 Fernando Moyano +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import logging +import tkinter +from tkinter import font + +import zynautoconnect +from zyngui import zynthian_gui_config +from zyngui.zynthian_gui_base import zynthian_gui_base + +DRAG_THRESHOLD = 5 + + +class zynthian_gui_chain_manager(zynthian_gui_base): + """ + View of chains. + + This class handles the graphical representation of chains and their processors. + It supports navigation via encoders/keys and mouse/touch interactions for + scrolling, selecting and operating on processors. + """ + + def __init__(self): + """ + Initialize the Chain View. + + Sets up the canvas, data structures for nodes and grid navigation, + and initializes mouse drag state variables. + """ + super().__init__('Chain View') + + # Canvas for drawing the graph + self.canvas = tkinter.Canvas(self.main_frame, + bg=zynthian_gui_config.color_panel_bg, + highlightthickness=0) + self.canvas.pack(fill=tkinter.BOTH, expand=True) + + # Nodes mapping: + self.nodes = [] # Node graph - [chain_idx, row_idx, col_idx] + self.selected_node = [0, 0, 0] # [chain_idx, row_idx, col_idx] + self.moving_proc = None # The processor object being moved + self.moving_chain = False # True if moving a chain left/right + self.rows = 0 # Quantity of rows in longest chain + + # Mouse Drag State + self.press_event = None + self.dragging = False + self.font = (zynthian_gui_config.font_family, int(0.026 * self.height)) + self.BLOCK_WIDTH = 120 # Width of each processor block in pixels + self.BLOCK_HEIGHT = 40 # Height of each processor block in pixels + self.H_SPACING = 10 # Horizontal spacing between processor blocks in pixels + self.V_SPACING = 10 # Vertical spacing between processor blocks in pixels + + self.last_active_proc = None # The last processor to be selected + self.long_press_id = None + + def build_view(self): + """ + Set up the view for the current chain. + + Sets the title, binds input events (mouse/touch), draws the initial graph, + and sets the initial selection. + + Returns: + bool: Always True. + """ + self.set_title(f"Chain: {self.chain_manager.active_chain.get_name()}") + + if zynthian_gui_config.enable_touch_navigation: + self.show_back_button() + + # Bind Mouse Events + self.canvas.bind("", self.on_press) + self.canvas.bind("", self.on_motion) + self.canvas.bind("", self.on_release) + self.canvas.bind("", self.on_wheel) + self.canvas.bind("", self.on_wheel) + #self.build_graph(self.zyngui.get_current_processor()) + if (self.nodes and self.selected_node[0] != self.chain_manager.get_active_chain_index()) or self.zyngui.get_current_processor() != self.last_active_proc: + self.build_graph(self.zyngui.get_current_processor()) + else: + self.build_graph() + return True + + def update_layout(self): + super().update_layout() + self.font = (zynthian_gui_config.font_family, int(0.026 * self.height)) + # Formual 2 * (x // y) ensures even values which helps with spacing and dividers + self.BLOCK_WIDTH = 2 * (self.width // 12) + self.BLOCK_HEIGHT = 2 * (self.height // 16) + self.H_SPACING = 2 * (self.BLOCK_WIDTH // 28) + self.V_SPACING = 2 * (self.BLOCK_HEIGHT // 8) + shown = self.shown + self.shown = False + self._draw_graph() + self.shown = shown + + def hide(self): + if self.shown: + self.end_moving_chain() + self.end_moving_processor() + self.last_active_proc = self.zyngui.get_current_processor() + super().hide() + + def on_press(self, event): + """ + Handle mouse button press. Initializes drag state. + Args: + event: Mouse event + """ + + # Record start position for drag + self.press_event = event + self.dragging = False + # Find clicked node + x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y) + self.start_xview = self.canvas.xview()[0] + self.start_yview = self.canvas.yview()[0] + self.clicked_node = self.get_node_at(x, y) + if self.clicked_node: + self.select_node(node=self.clicked_node) + self.long_press_id = self.canvas.after(800, self.on_long_press) + + def on_long_press(self): + """ Handle press and hold""" + + if not self.long_press_id: + return + self.long_press_id = None + node = self._get_node(self.selected_node) + + if "proc" in node: + proc = node["proc"] + if proc == "chain_options": + if node["chain_id"] == 0: + self.zyngui.show_screen(proc) + else: + self.start_moving_chain() + elif type(proc) != str: + self.start_moving_processor(node["proc"]) + + def get_node_at(self, x, y): + for obj_id in self.canvas.find_overlapping(x, y, x, y): + try: + return self.node2pos[obj_id] + except: + pass + return None + + def on_motion(self, event): + """ + Handle mouse drag event. Scrolls the canvas. + Args: + event: Mouse event + """ + + # Calculate pixel delta + dx = self.press_event.x - event.x + dy = self.press_event.y - event.y + + # Check threshold + if not self.dragging: + if abs(dx) > DRAG_THRESHOLD or abs(dy) > DRAG_THRESHOLD: + self.dragging = True + if self.long_press_id: + self.canvas.after_cancel(self.long_press_id) + self.long_press_id = None + + if self.dragging: + if self.moving_chain: + x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y) + node = self.get_node_at(x, y) + if node and node["chain_id"] != self.clicked_node["chain_id"]: + if event.x > self.press_event.x: + self.arrow_right() + else: + self.arrow_left() + self.press_event.x = event.x + self.clicked_node = node + elif self.moving_proc: + x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y) + node = self.get_node_at(x, y) + if not node: + # Dragged into space + pass + if node and self.clicked_node and node != self.clicked_node: + if node["chain_id"] != self.clicked_node["chain_id"]: + if event.x > self.press_event.x: + self.arrow_right() + else: + self.arrow_left() + elif dy > self.BLOCK_HEIGHT: + if self.chain_manager.nudge_processor(self.chain_manager.active_chain.chain_id, self.moving_proc, True): + self.build_graph(self.moving_proc) + self.press_event.y = event.y + elif dy < -self.BLOCK_HEIGHT: + if self.chain_manager.nudge_processor(self.chain_manager.active_chain.chain_id, self.moving_proc, False): + self.build_graph(self.moving_proc) + self.press_event.y = event.y + else: + return + self.clicked_node = node + else: + # Scroll Canvas manually using moveto + # We need the total scrollable size to convert pixels to fraction + try: + # scrollregion is "x1 y1 x2 y2" string or tuple + sr = self.canvas.cget("scrollregion") + if isinstance(sr, str): + sr = [float(x) for x in sr.split()] + sr_w = sr[2] - sr[0] + sr_h = sr[3] - sr[1] + can_w = self.canvas.winfo_width() + can_h = self.canvas.winfo_height() + + # Horizontal Move + if sr_w > can_w: + d_fract_x = dx / float(sr_w) + self.canvas.xview_moveto(self.start_xview + d_fract_x) + + # Vertical Move + if sr_h > can_h: + d_fract_y = dy / float(sr_h) + self.canvas.yview_moveto(self.start_yview + d_fract_y) + + except Exception as e: + logging.warning(f"Can't drag scroll => {e}") + + def on_release(self, event): + """ + Handle mouse button release. + Args: + event: Mouse event + """ + if self.long_press_id: + self.canvas.after_cancel(self.long_press_id) + self.long_press_id = None + else: + return + press_type = "S" + if self.press_event: + if event.time > self.press_event.time + 400: + self.press_time = None + press_type = "B" + self.clicked_node = None + # If dragging, stop. + if self.dragging: + self.dragging = False + return + + # Handle Click Selection + # Use canvasx/y to account for scrolling + x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y) + + # Find clicked node + node = self.get_node_at(x, y) + if node is None: + return + #self.select_node(node["pos"]) + self.on_select(t=press_type) + + def _add_node(self, chain_idx, row, title, chain_id, proc="", slot=None, idx=None): + """ Adds a node to the graph + + Args: + chain_idx: The chain where the node will be added. + row: The row where the node will be added. + title: The title of the node. + chain_id: The chain id of the node. + proc_id: The processor of the node or string describing node type. None for non-processor nodes. + """ + while len(self.nodes) <= chain_idx: + self.nodes.append([]) + while len(self.nodes[chain_idx]) <= row: + self.nodes[chain_idx].append([]) + if type(proc) == str: + proc_type = proc + else: + proc_type = proc.type + self.nodes[chain_idx][row].append({ + "title": title, # Title shown in GUI + "chain_id": chain_id, # zynthian chain_id (not necessarily display position) + "proc": proc, # Processor object or symbol for non-processor nodes + "slot": slot, # Processor slot + "idx": idx, # Index of (parallel) processor within slot + "pos": [chain_idx, row, len(self.nodes[chain_idx][row])], # Position of node within graph + "is_dst": proc_type in ("MIDI Synth", "Audio Effect", "MIDI Tool", "Special", "midi_key_range", "add_midi_proc", "midi_output", "audio_out"), + "is_src": proc_type in ("MIDI Synth", "Audio Generator", "Audio Effect", "MIDI Tool", "Special", "midi_key_range", "midi_input", "add_midi_proc", "audio_in") + }) + + def _get_name(self, text, max_width): + """ + Trim text so that its pixel width fits within max_width. + Adds an ellipsis (…) if trimmed. + """ + node_font = font.Font(family=self.font[0], size=self.font[1]) + if node_font.measure(text) <= max_width: + return text # already fits + + ellipsis = "…" + ellipsis_width = node_font.measure(ellipsis) + + # Start trimming from the end + for i in range(len(text), 0, -1): + sub = text[:i] + if node_font.measure(sub) + ellipsis_width <= max_width: + return sub.strip() + ellipsis + return ellipsis # fallbackpass + + def build_graph(self, proc=None): + """ + Draw the entire processor chain graph on the canvas. + + Clears the canvas and rebuilds the node structure based on each + chain's configuration (Inputs -> MIDI Tools -> Synths -> + Audio Effects -> Outputs). Updates the scroll region. + + Args: + proc: The processor to select. (None to use current selection) + """ + self.nodes = [] + + self.rows = 0 + for chain_idx, chain in enumerate(self.chain_manager.chains.values()): + chain_id = chain.chain_id + row = 0 + + # Add chain option button + name = self._get_name(chain.get_name(), self.BLOCK_WIDTH) + self._add_node(chain_idx, row, f"{name}", chain_id, "chain_options") + row += 1 + # Add MIDI input + if chain.is_midi(): + self._add_node(chain_idx, row, "MIDI Input", chain_id, "midi_input") + row += 1 + self._add_node(chain_idx, row, "Key Range & Transpose", chain_id, "midi_key_range") + row += 1 + # Add MIDI processors + for slot_idx, slot in enumerate(chain.midi_slots): + for proc_idx, processor in enumerate(slot): + self._add_node(chain_idx, row, processor.get_name(), chain_id, processor, slot_idx, proc_idx) + if self.nodes[chain_idx][row]: + row += 1 + # Add MIDI output + if chain.synth_slots: + # Add synth + for slot_idx, slot in enumerate(chain.synth_slots): + for proc_idx, processor in enumerate(slot): + self._add_node(chain_idx, row, processor.get_name(), chain_id, processor, slot_idx, proc_idx) + if self.nodes[chain_idx][row]: + row += 1 + elif chain.is_midi(): + if not chain.midi_slots: + self._add_node(chain_idx, row, "+", chain_id, "add_midi_proc") + row += 1 + self._add_node(chain_idx, row, "MIDI Output", chain_id, "midi_output") + row += 1 + # Add audio input + if chain.audio_thru and chain.zynmixer_proc and chain.zynmixer_proc.eng_code != "MR": + self._add_node(chain_idx, row, "Audio Input", chain_id, "audio_in") + row += 1 + # Add audio processors + for slot_idx, slot in enumerate(chain.audio_slots): + for proc_idx, processor in enumerate(slot): + self._add_node(chain_idx, row, processor.get_name(), chain_id, processor, slot_idx, proc_idx) + if self.nodes[chain_idx][row]: + row += 1 + # Add audio output + if chain.is_audio(): + self._add_node(chain_idx, row, "Audio Output", chain_id, "audio_out") + row += 1 + self.rows = max(self.rows, row) + self._draw_graph(proc) + + def _draw_node(self, node, x, y): + """ Draw a single node on the canvas. + + Args: + node: The node object to be drawn. + """ + # Colors + c_midi = "#805050" + c_synth = "#32a893" + c_audio = "#505080" + c_special = "#708050" + + # Draw node background + proc = node.get("proc") + bg_col = "#505050" + fg_col = "#ffffff" + disabled = False + title = node.get("title") + if type(proc) is str: + match proc: + case "midi_input" | "note_range" | "add_midi_proc" | "midi_output" | "midi_key_range": + bg_col = c_midi + case "audio_in" | "audio_out": + bg_col = c_audio + else: + match proc.type: + case "MIDI Input" | "MIDI Output" | "MIDI Tool": + bg_col = c_midi + case "MIDI Synth" | "Audio Generator": + bg_col = c_synth + case "Audio Input" | "Audio Output" | "Audio Effect": + bg_col = c_audio + case "Special": + bg_col = c_special + if proc.type == "Audio Effect": + try: + if proc.controllers_dict["bypass"].value: + disabled = True + fg_col = "#808080" + except: + pass + node["id"] = self.canvas.create_rectangle( + x, y, x + self.BLOCK_WIDTH, y + self.BLOCK_HEIGHT, + fill=bg_col, outline=bg_col, tags="node" + ) + # Draw node text + text_id = self.canvas.create_text( + x + self.BLOCK_WIDTH / 2, y + self.BLOCK_HEIGHT / 2, + text=title, fill=fg_col, + font=self.font, + width=self.BLOCK_WIDTH, + justify=tkinter.CENTER + ) + while True: + x0, y0, x1, y1 = self.canvas.bbox(text_id) + if y1 - y0 < self.BLOCK_HEIGHT: + break + title = title[:-1].strip() + self.canvas.itemconfig(text_id, text=f"{title}...") + self.node2pos[node["id"]] = node + + def _draw_line(self, start_id, end_id): + xa, ya, xb, yb = self.canvas.bbox(start_id) + x0 = xa + (xb - xa) // 2 + y0 = ya + (yb - ya) // 2 + xa, ya, xb, yb = self.canvas.bbox(end_id) + x1 = xa + (xb - xa) // 2 + y1 = ya + (yb - ya) // 2 + self.canvas.create_line(x0, y0, x1, y1, fill="#AAAAAA", width=2, tags="lines") + + def _draw_graph(self, sel_proc=None): + if self.width == 1: + return # Not yet resized + div = self.chain_manager.get_pinned_pos() + self.canvas.delete("all") + self.node2pos = {} # Dict of nodes, mapped by gui object (background rectangle) + divider_height = self.rows * (self.BLOCK_HEIGHT + self.V_SPACING) + chain_offset = 0 + for chain_idx, chain in enumerate(self.nodes): + y = self.H_SPACING // 2 + cols_in_chain = 1 # max number of parallel processors + for row_idx, row in enumerate(chain): + x = chain_offset + for col, node in enumerate(row): + self._draw_node(node, x, y) + # Create interconnect lines + if row_idx > 0: + x0 = x + self.BLOCK_WIDTH // 2 + is_dst = node.get("is_dst", False) + if col < len(chain[row_idx - 1]): + is_src = chain[row_idx - 1][col].get("is_src", False) + else: + is_src = False + if is_dst: + y0 = y - self.V_SPACING // 2 + if is_src: + self.canvas.create_line(x0, y, x0, y - self.V_SPACING, fill="#AAAAAA", width=2, tags="lines") + else: + self.canvas.create_line(x0, y, x0, y0, fill="#AAAAAA", width=2, tags="lines") + if col > 0: + self.canvas.create_line(x0, y0, x0 - self.BLOCK_WIDTH - self.H_SPACING, y0, width=2, fill="#AAAAAA", tags="lines") + if row_idx < len(chain) - 1 and col >= len(chain[row_idx + 1]): + y0 = y + self.BLOCK_HEIGHT + y1 = y0 + self.V_SPACING // 2 + self.canvas.create_line(x0, y0, x0, y1, fill="#AAAAAA", width=2, tags="lines") + self.canvas.create_line(x0, y1, x0 - self.BLOCK_WIDTH - self.H_SPACING, y1, width=2, fill="#AAAAAA", tags="lines") + + x += self.BLOCK_WIDTH + self.H_SPACING + if col >= cols_in_chain: + cols_in_chain = col + 1 + y += self.BLOCK_HEIGHT + self.V_SPACING + + if self.moving_chain and self.selected_node[0] == chain_idx: + # Highlight chain being moved + self.canvas.create_rectangle( + chain_offset - 1, 0, chain_offset + 1 + self.BLOCK_WIDTH, divider_height, + outline="yellow", + width=3, + fill="", + tags="chain_move" + ) + + x = chain_offset - self.H_SPACING / 2 + if chain_idx == div: + x_div = x + self.canvas.create_line(x, 0, x, divider_height, fill="#666666", width=1, tags="lines") + chain_offset += (self.BLOCK_WIDTH + self.H_SPACING) * cols_in_chain + + # Background for pinned chains + try: + x = x_div + except: + pass + main_bg = self.canvas.create_rectangle( + x, 0, chain_offset, divider_height, + outline="", + width=0, + fill="#333333" + ) + + self.canvas.lower("lines") + self.canvas.lower(main_bg) + + # Configure scroll region + bbox = self.canvas.bbox("all") + if bbox: + self.canvas.configure(scrollregion=(bbox[0], bbox[1] - 5, bbox[2], bbox[3] + 5)) + else: + self.canvas.configure(scrollregion=(0, 0, 100, 100)) + self.select_node(proc=sel_proc) + + def _draw_selection(self): + """ + Draw selection cursor. + """ + self.canvas.itemconfig("node", outline="") + if not self.selected_node: + self.selected_node = [0, 0, 0] + if self.moving_proc: + color = "yellow" + else: + color = "white" + try: + chain_idx, col_idx, row_idx = self.selected_node + node_id = self.nodes[chain_idx][col_idx][row_idx]["id"] + if not self.moving_chain: + self.canvas.itemconfig(node_id, outline=color, width=2) + except: + pass + + #Scroll the canvas to ensure the selected node is visible. + self.canvas.update_idletasks() # Ensure all redrawing has completed + # Get node's coords + x0, y0, x1, y1 = self.canvas.bbox(node_id) + # Get view coords + vw = self.width + vh = self.height + vx0 = self.canvas.canvasx(0) + vy0 = self.canvas.canvasy(0) + vx1 = self.canvas.canvasx(vw) + vy1 = self.canvas.canvasy(vh) + b0, b1, b2, b3 = self.canvas.bbox("all") + w = b2 - b0 + h = b3 - b1 + # Scroll horizontally to show selected block plus 20% of next block to indicate if more scrolling possible + if x0 < vx0: + target_x = (x0 - b0 - 0.2 * self.BLOCK_WIDTH) / w + elif x1 > vx1: + target_x = (x1 - vw + 0.2 * self.BLOCK_WIDTH) / w + else: + target_x = None + # Scroll vertically + if y0 < vy0: + target_y = target_y=(y0 - b1 - 0.3 * self.BLOCK_HEIGHT) / h + elif y1 > vy1: + target_y = target_y=(y1 - vh + 0.3 * self.BLOCK_HEIGHT + self.V_SPACING) / h + else: + target_y = None + if target_x or target_y: + if self.shown: + self.smooth_scroll_to(target_x, target_y) + else: + if target_x is not None: + self.canvas.xview_moveto(target_x) + if target_y is not None: + self.canvas.yview_moveto(target_y) + + def smooth_scroll_to(self, target_x=None, target_y=None, steps=30, delay=10): + start_x, start_y = self.canvas.xview()[0], self.canvas.yview()[0] + dx = dy = 0 + if target_x is not None: + dx = (target_x - start_x) / steps + if target_y is not None: + dy = (target_y - start_y) / steps + + def step(i=0): + if i >= steps: + return + if target_x is not None: + self.canvas.xview_moveto(start_x + dx * i) + if target_y is not None: + self.canvas.yview_moveto(start_y + dy * i) + self.canvas.after(delay, step, i + 1) + + step() + + def _get_node(self, node_pos): + try: + chain_idx, row, col = node_pos + return self.nodes[chain_idx][row][col] + except: + pass + return None + + def select_chain_options_node(self): + chain_idx = self.chain_manager.get_chain_index(self.chain_manager.active_chain.chain_id) + self.selected_node = [chain_idx, 0, 0] + + def get_node_pos(self, node): + for chain_idx, c in enumerate(self.nodes): + for row_idx, r in enumerate(c): + for col_idx, n in enumerate(r): + if n == node: + return [chain_idx, row_idx, col_idx] + return [0, 0, 0] + + def select_node(self, node_pos=None, node=None, proc=None): + if not self.nodes: + return + if node: + self.selected_node = self.get_node_pos(node) + elif proc: + for chain_idx, chain in enumerate(self.nodes): + for row_idx, row in enumerate(chain): + for node_idx, node in enumerate(row): + if node.get("proc") == proc: + node_pos = [chain_idx, row_idx, node_idx] + break + if node_pos: + self.selected_node = node_pos + elif not self.selected_node: + self.selected_node = [0, 0, 0] + chain_idx, row, col = self.selected_node + # Range check + if chain_idx >= len(self.chain_manager.chains): + chain_idx = len(self.chain_manager.chains) - 1 + if row >= len(self.nodes[chain_idx]): + row = len(self.nodes[chain_idx]) - 1 + if col >= len(self.nodes[chain_idx][row]): + col = len(self.nodes[chain_idx][row]) - 1 + self.selected_node = [chain_idx, row, col] + if not proc: + proc = self.nodes[chain_idx][row][col]["proc"] + + node = self._get_node(self.selected_node) + chain_id = node.get("chain_id") + self.chain_manager.set_active_chain_by_id(chain_id) + if type(proc) != str: + self.zyngui.set_current_processor(proc) + self._draw_selection() + chain = self.chain_manager.chains[chain_id] + self.set_title(f"Chain: {chain.get_name()}") + + def move_processor(self, chain_idx, chain_offset): + if self.moving_proc.eng_code in ["MI", "MR"]: + return + try: + node = self._get_node(self.selected_node) + ordered_chains = list(self.chain_manager.chains) + chain_id = ordered_chains[chain_idx] + chain = self.chain_manager.chains[chain_id] + chain_dest_id = ordered_chains[chain_idx + chain_offset] + chain_dst = self.chain_manager.chains[chain_dest_id] + # Constrain which chains a process may be moved to + if self.moving_proc.type == "MIDI Tool": + if not chain_dst.is_midi(): + return + elif self.moving_proc.type == "Audio Effect": + if not chain_dst.is_audio(): + return + chain.remove_processor(self.moving_proc) + chain_dst.insert_processor(self.moving_proc, node.get("slot")) + # Rebuild routing in both chains + if self.moving_proc.type == "MIDI Tool": + chain.rebuild_midi_graph() + chain_dst.rebuild_midi_graph() + zynautoconnect.request_midi_connect(True) + elif self.moving_proc.type == "Audio Effect": + chain.rebuild_audio_graph() + chain_dst.rebuild_audio_graph() + zynautoconnect.request_audio_connect(True) + except Exception as e: + logging.error(f"Can't move processor! => {e}") + self.build_graph(self.moving_proc) + + def start_moving_processor(self, processor=None): + """ + Enter 'Move Mode' for a specific processor. + + Args: + processor: The processor object to be moved. Default: Current processor of current chain. + """ + + if processor: + self.moving_proc = processor + else: + chain = self.chain_manager.active_chain + if chain.chain_id == 0: + return + self.moving_proc = chain.current_processor + if self.moving_proc and not self.chain_manager.can_move_processor(self.moving_proc): + self.moving_proc = None + self.select_node(proc=self.moving_proc) + + def end_moving_processor(self): + """ Exit processor move mode + """ + + self.moving_proc = None + + def start_moving_chain(self): + self.moving_chain = True + self._draw_graph(self.moving_proc) + + def end_moving_chain(self): + if not self.moving_chain: + return + self.moving_chain = False + self.strip_drag_start = None + self.canvas.delete("chain_move") + self.select_node() + + def arrow_down(self): + """ + Handle arrow down action. + Moves selection down or nudges processor if in move mode. + """ + if self.moving_chain: + return + if self.moving_proc: + proc = self.moving_proc + self.chain_manager.nudge_processor(self.chain_manager.active_chain.chain_id, proc, False) + self.build_graph(proc) + else: + chain_idx, row, col = self.selected_node + row += 1 + if row >= len(self.nodes[chain_idx]): + return + self.select_node([chain_idx, row, col]) + + def arrow_up(self): + """ + Handle arrow up action. + Moves selection up or nudges processor if in move mode. + """ + if self.moving_chain: + return + if self.moving_proc: + proc = self.moving_proc + self.chain_manager.nudge_processor(self.chain_manager.active_chain.chain_id, proc, True) + self.build_graph(proc) + else: + chain_idx, row, col = self.selected_node + row -= 1 + if row < 0: + return + self.select_node([chain_idx, row, col]) + + def arrow_left(self): + """ + Handle arrow left action. + Moves selection left or nudges processor if in move mode. + """ + chain_idx, row, col = self.selected_node + if self.moving_proc: + if chain_idx: + self.move_processor(chain_idx, -1) + elif self.moving_chain: + self.selected_node[0] = self.chain_manager.nudge_chain(-1) + self.build_graph() + else: + col -= 1 + if col < 0: + # Beginning of row, try previous chain + if chain_idx == 0: + return + chain_idx -= 1 + row = min(row, len(self.nodes[chain_idx]) - 1) + col = len(self.nodes[chain_idx][row]) - 1 + self.select_node([chain_idx, row, col]) + + def arrow_right(self): + """ + Handle arrow right action. + Moves selection right or nudges processor if in move mode. + """ + chain_idx, row, col = self.selected_node + if self.moving_proc: + self.move_processor(chain_idx, 1) + elif self.moving_chain: + self.selected_node[0] = self.chain_manager.nudge_chain(1) + self.build_graph() + else: + col += 1 + if col >= len(self.nodes[chain_idx][row]): + # End of row, try next chain + chain_idx += 1 + if chain_idx >= len(self.nodes): + return + col = 0 + # Check we're not beyond end of chain + row = min(row, len(self.nodes[chain_idx]) - 1) + self.select_node([chain_idx, row, col]) + + def select_offset(self, dval): + if self.moving_proc: + if dval > 0: + self.arrow_down() + elif dval < 0: + self.arrow_up() + return + if self.moving_chain: + self.selected_node[0] = self.chain_manager.nudge_chain(dval) + self.build_graph() + return + + chain_idx, row, col = self.selected_node + col += dval + if col >= len(self.nodes[chain_idx][row]): + # End of row, try next row + row += 1 + if row >= len(self.nodes[chain_idx]): + return + col = 0 + elif col < 0: + row -= 1 + if row < 0: + return + col = len(self.nodes[chain_idx][row]) - 1 + self.select_node([chain_idx, row, col]) + + def on_wheel(self, event): + """ + Handle mouse wheel events to navigate the graph. + + Args: + event: The mouse wheel event. + """ + if event.state: + if event.num == 5: + self.arrow_right() + else: + self.arrow_left() + else: + if event.num == 5: + self.arrow_up() + else: + self.arrow_down() + + def toggle_menu(self): + if self.shown: + self.zyngui.show_screen("admin") + + def zynpot_cb(self, i, dval): + if super().zynpot_cb(i, dval): + return True + if i == 2: + self.select_offset(dval) + return True + elif i == 3: + if dval > 0: + self.arrow_right() + elif dval < 0: + self.arrow_left() + + def back_action(self): + if self.moving_proc: + self.end_moving_processor() + self.select_node() + return True # Consumed + if self.moving_chain: + self.end_moving_chain() + return True + return False + + def switch_select(self, t='S'): + # Pass type to on_select + return self.on_select(t) + + def on_select(self, t='S'): + """ Handle selection event (Select/Enter key or Click). + Args: + t (str): Press type ('S' for short, 'B' for bold/long). + Returns: + bool: True if event consumed. + """ + + # If moving, consume event and exit + if self.moving_chain: + self.end_moving_chain() + if t == "S": + return True + + if self.moving_proc: + self.end_moving_processor() + self.select_node() + if t == "S": + return True + + if not self.selected_node: + self.selected_node = [0, 0, 0] + self.select_node() + return True + + chain_idx, col_idx, row_idx = self.selected_node + node = self.nodes[chain_idx][col_idx][row_idx] + proc = node.get("proc") + if t == "B": + if type(proc) == str: + chain = self.chain_manager.active_chain + if proc == "chain_options": + if chain.chain_id != 0: + self.start_moving_chain() + return True + else: + self.start_moving_processor(proc) + elif t == "S": + if type(proc) == str: + match proc: + case "chain_options": + pass + case "midi_key_range": + self.zyngui.screens['midi_key_range'].config(self.chain_manager.active_chain) + case "midi_input": + self.zyngui.screens['midi_config'].set_chain(self.chain_manager.active_chain) + self.zyngui.screens['midi_config'].input = True + proc = 'midi_config' + case "add_midi_proc": + self.zyngui.modify_chain({ + "chain_id": self.chain_manager.active_chain.chain_id, + "type": "MIDI Tool", + "midi_thru": True, + "audio_thru": False, + "slot": None + }) + return True + case "midi_output": + self.zyngui.screens['midi_config'].set_chain(self.chain_manager.active_chain) + self.zyngui.screens['midi_config'].input = False + proc = 'midi_config' + case "audio_in": + pass + case "audio_out": + pass + self.zyngui.show_screen(proc) + else: + self.zyngui.show_screen("processor_options") + return True + + def switch(self, swi, t): + """ Function to handle switches press + swi: Switch index [0=Layer, 1=Back, 2=Snapshot, 3=Select] + t: Press type ["S"=Short, "B"=Bold, "L"=Long] + + returns True if action fully handled or False if parent action should be triggered + """ + + if swi == 2: + if t == "S": + self.zyngui.screens["chain_options"].insert_chain() + return True + elif swi == 3: + return self.on_select(t) + return False + + def cuia_v5_zynpot_switch(self, params): + i = params[0] + t = params[1].upper() + if t == 'B' and i == 2: + self.zyngui.show_screen("chain_options") + return True + return self.switch(i, t) diff --git a/zyngui/zynthian_gui_chain_menu.py b/zyngui/zynthian_gui_chain_menu.py index c7c74d500..c7ca9fc1d 100644 --- a/zyngui/zynthian_gui_chain_menu.py +++ b/zyngui/zynthian_gui_chain_menu.py @@ -41,38 +41,39 @@ def __init__(self): def fill_list(self): self.list_data = [] - try: - self.zyngui.chain_manager.get_next_free_mixer_chan() - mixer_avail = True - except: - mixer_avail = False self.list_data.append((None, 0, "> ADD CHAIN")) - if mixer_avail: - self.list_data.append((self.add_synth_chain, 0, - "Add Instrument Chain", - ["Create a new chain with a MIDI-controlled synth engine. The chain receives MIDI input and generates audio output.", - "midi_instrument.png"])) - self.list_data.append((self.add_audiofx_chain, 0, - "Add Audio Chain", - ["Create a new chain for audio FX processing. The chain receives audio input and generates audio output.", - "microphone.png"])) + self.list_data.append((self.add_synth_chain, 0, + "Add Instrument Chain", + ["Create a new chain with a MIDI-controlled synth engine. The chain receives MIDI input and generates audio output.", + "midi_instrument.png"])) + self.list_data.append((self.add_audio_chain, 0, + "Add Audio Input Chain", + ["Create a new chain for audio FX processing. The chain receives audio input and generates audio output.", + "microphone.png"])) + self.list_data.append((self.add_clippy_chain, 0, + "Add Clip Chain", + ["Create a new chain with audio clip launcher. The chain receives trigger/stop events from the sequencer and generates audio output.", + "audio.png"])) + self.list_data.append((self.add_mixbus_chain, 0, + "Add Mixbus Chain", + ["Create a mixbus chain for processing audio (FX send).", + "effects_loop.png"])) self.list_data.append((self.add_midifx_chain, 0, "Add MIDI Chain", ["Create a new chain for MIDI processing. The chain receives MIDI input and generates MIDI output.", "midi_logo.png"])) - if mixer_avail: - self.list_data.append((self.add_midiaudiofx_chain, 0, - "Add MIDI+Audio Chain", - ["Create a new chain for combined audio + MIDI processing. The chain receives audio & MIDI input and generates audio & MIDI output. Use it with vocoders, autotune, etc.", - "midi_audio.png"])) - self.list_data.append((self.add_generator_chain, 0, - "Add Audio Generator Chain", - ["Create a new chain for audio generation. The chain doesn't receive any input and generates audio output. Internet radio, test signals, etc.", - "audio_generator.png"])) - self.list_data.append((self.add_special_chain, 0, - "Add Special Chain", - ["Create a new chain for special processing. The chain receives audio & MIDI input and generates audio & MIDI output. use it for MOD-UI, puredata, etc.", - "special_chain.png"])) + self.list_data.append((self.add_midiaudiofx_chain, 0, + "Add MIDI+Audio Chain", + ["Create a new chain for combined audio + MIDI processing. The chain receives audio & MIDI input and generates audio & MIDI output. Vocoders, autotune, etc.", + "midi_audio.png"])) + self.list_data.append((self.add_generator_chain, 0, + "Add Audio Generator Chain", + ["Create a new chain for audio generation. The chain doesn't receive any input and generates audio output. Internet radio, test signals, etc.", + "audio_generator.png"])) + self.list_data.append((self.add_special_chain, 0, + "Add Special Chain", + ["Create a new chain for special processing. The chain receives audio & MIDI input and generates audio & MIDI output. MOD-UI, puredata, etc.", + "special_chain.png"])) self.list_data.append((None, 0, "> REMOVE")) self.list_data.append((self.remove_sequences, 0, @@ -98,10 +99,18 @@ def add_synth_chain(self, t='S'): self.zyngui.modify_chain( {"type": "MIDI Synth", "midi_thru": False, "audio_thru": False}) - def add_audiofx_chain(self, t='S'): + def add_audio_chain(self, t='S'): self.zyngui.modify_chain( {"type": "Audio Effect", "midi_thru": False, "audio_thru": True}) + def add_clippy_chain(self, t='S'): + self.zyngui.modify_chain( + {"type": "Audio Generator", "midi_thru": False, "audio_thru": False, "engine": "CL", "midi_chan": None}) + + def add_mixbus_chain(self, t='S'): + self.zyngui.modify_chain( + {"type": "Audio Effect", "midi_thru": False, "audio_thru": True, "mixbus": True}) + def add_midifx_chain(self, t='S'): self.zyngui.modify_chain( {"type": "MIDI Tool", "midi_thru": True, "audio_thru": False}) diff --git a/zyngui/zynthian_gui_chain_options.py b/zyngui/zynthian_gui_chain_options.py index 134708cc0..218e1ad65 100644 --- a/zyngui/zynthian_gui_chain_options.py +++ b/zyngui/zynthian_gui_chain_options.py @@ -5,7 +5,7 @@ # # Zynthian GUI Chain Options Class # -# Copyright (C) 2015-2023 Fernando Moyano +# Copyright (C) 2015-2025 Fernando Moyano # # ****************************************************************************** # @@ -40,171 +40,72 @@ class zynthian_gui_chain_options(zynthian_gui_selector_info): def __init__(self): super().__init__('Option') self.index = 0 - self.chain = None - self.chain_id = None - self.processor = None - - def setup(self, chain_id=None, proc=None): - self.index = 0 - self.chain = self.zyngui.chain_manager.get_chain(chain_id) - self.chain_id = self.chain.chain_id - self.processor = proc + self.chain = self.zyngui.chain_manager.active_chain def fill_list(self): self.list_data = [] synth_proc_count = self.chain.get_processor_count("Synth") midi_proc_count = self.chain.get_processor_count("MIDI Tool") - audio_proc_count = self.chain.get_processor_count("Audio Effect") - - if self.chain.is_midi(): - self.list_data.append((self.chain_note_range, None, "Note Range & Transpose", - ["Configure note range and transpose by octaves and semitones.", "note_range.png"])) - self.list_data.append((self.chain_midi_capture, None, "MIDI In", - ["Manage MIDI input sources. Enable/disable MIDI sources, toggle active/multi-timbral mode, load controller drivers, etc.", "midi_input.png"])) - - if self.chain.midi_thru: - self.list_data.append((self.chain_midi_routing, None, "MIDI Out", - ["Manage MIDI output routing to external devices and other chains.", "midi_output.png"])) - - if self.chain.is_midi(): - try: - if synth_proc_count == 0 or self.chain.synth_slots[0][0].engine.options["midi_chan"]: - self.list_data.append((self.chain_midi_chan, None, "MIDI Channel", - ["Select MIDI channel to receive from.", "midi_settings.png"])) - except Exception as e: - logging.error(e) - - if self.chain.is_midi() and synth_proc_count: - self.list_data.append((self.chain_midi_cc, None, "MIDI CC", - ["Select MIDI CC numbers passed-thru to chain processors. It could interfere with MIDI-learning. Use with caution!", "midi_settings.png"])) - - if not zynthian_gui_config.check_wiring_layout(["Z2", "V5"]) and self.chain.get_processor_count(): - self.list_data.append((self.midi_learn, None, "MIDI Learn", - ["Enter MIDI-learning mode for processor parameters.", "midi_learn.png"])) - - if self.chain.audio_thru and self.chain_id != 0: - self.list_data.append((self.chain_audio_capture, None, "Audio In", - ["Manage audio capture sources.", "audio_input.png"])) - - if self.chain.is_audio(): - self.list_data.append((self.chain_audio_routing, None, "Audio Out", - ["Manage audio output routing.", "audio_output.png"])) - - if self.chain.is_audio(): - self.list_data.append((self.audio_options, None, "Mixer Options", - ["Extra audio mixer options.", "audio_options.png"])) + audio_proc_count = max(0, self.chain.get_processor_count("Audio Effect") - 1) - # TODO: Catch signal for Audio Recording status change - if self.chain_id == 0 and not zynthian_gui_config.check_wiring_layout(["Z2", "V5"]): - if self.zyngui.state_manager.audio_recorder.status: - self.list_data.append((self.toggle_recording, None, "■ Stop Audio Recording", - ["Stop audio recording", "audio_recorder.png"])) - else: - self.list_data.append((self.toggle_recording, None, "⬤ Start Audio Recording", - ["Start audio recording", "audio_recorder.png"])) + self.list_data.append((None, None, "> Manage this chain")) - self.list_data.append((None, None, "> Processors")) + self.list_data.append((self.rename_chain, None, "Rename chain", + ["Rename the chain. Clear name to reset to default name.", "rename.png"])) - if self.chain.is_midi(): - # Add MIDI-FX options - self.list_data.append((self.midifx_add, None, "Add MIDI-FX", - ["Add a new MIDI processor to process chain's MIDI input.", "midi_processor.png"])) + if self.chain.chain_id: + self.list_data.append((self.move_chain, None, "Move chain", + ["Reposition the chain in the mixer view.", "move_left_right.png"])) - self.list_data += self.generate_chaintree_menu() + if not zynthian_gui_config.check_wiring_layout(["Z2", "V5"]) and self.chain.get_processor_count(): + self.list_data.append((self.midi_learn, None, "MIDI Learn", + ["Enter MIDI-learning mode for processor parameters.", "midi_learn.png"])) - if self.chain.is_audio(): - # Add Audio-FX options - self.list_data.append((self.audiofx_add, None, "Add Pre-fader Audio-FX", - ["Add a new audio processor to process chain's audio before the mixer's fader.", "audio_processor.png"])) - self.list_data.append((self.postfader_add, None, "Add Post-fader Audio-FX", - ["Add a new audio processor to process chain's audio after the mixer's fader.", "audio_processor.png"])) + if audio_proc_count > 0: + self.list_data.append((self.remove_all_audiofx, None, "Remove all Audio-FX", + ["Remove all audio-FX processors in this chain.", "delete_audio_processors.png"])) - if self.chain_id != 0: - self.list_data.append((self.export_chain, None, "Export chain as snapshot...", - ["Save the selected chain as a snapshot which may then be imported into another snapshot.", "snapshot_chains.png"])) + if self.chain.chain_id: if synth_proc_count * midi_proc_count + audio_proc_count == 0: - self.list_data.append((self.remove_chain, None, "Remove Chain", + self.list_data.append((self.remove_chain, None, "Remove chain", ["Remove this chain and all its processors.", "delete_chains.png"])) else: self.list_data.append((self.remove_cb, None, "Remove...", ["Remove chain or processors.", "delete_chains.png"])) - elif audio_proc_count > 0: - self.list_data.append((self.remove_all_audiofx, None, "Remove all Audio-FX", - ["Remove all audio-FX processors in this chain.", "delete_audio_processors.png"])) - self.list_data.append((None, None, "> GUI")) - self.list_data.append((self.rename_chain, None, "Rename chain", - ["Rename the chain. Clear name to reset to default name.", "rename.png"])) - if self.chain_id: - if len(self.zyngui.chain_manager.ordered_chain_ids) > 2: - self.list_data.append((self.move_chain, None, "Move chain ⇦ ⇨", - ["Reposition the chain in the mixer view.", "move_left_right.png"])) + self.list_data.append((self.export_chain, None, "Export chain as snapshot...", + ["Save the selected chain as a snapshot which may then be imported into another snapshot.", "snapshot_chains.png"])) - super().fill_list() + self.list_data.append((None, None, "> Global chain management")) + self.list_data.append((self.insert_chain, None, "Insert new chain", + ["Create a new chain and insert immediately before the selected chain.", "midi_instrument.png"])) + + if self.chain.chain_id == 0: + self.list_data.append((self.remove_sequences, 0, + "Remove Sequences", + ["Clean all sequencer data while keeping existing chains.", + "delete_sequences.png"])) + self.list_data.append((self.remove_chains, 0, + "Remove Chains", + ["Clean all chains while keeping sequencer data.", + "delete_chains.png"])) + self.list_data.append((self.remove_all, 0, + "Remove All", + ["Clean all chains and sequencer data. Start from scratch!", + "delete_all.png"])) - # Generate chain tree menu - def generate_chaintree_menu(self): - res = [] - indent = 0 - # Build MIDI chain - for slot in range(self.chain.get_slot_count("MIDI Tool")): - procs = self.chain.get_processors("MIDI Tool", slot) - num_procs = len(procs) - for index, processor in enumerate(procs): - name = processor.get_name() - if index == num_procs - 1: - text = " " * indent + "╰─ " + name - else: - text = " " * indent + "├─ " + name - - res.append((self.processor_options, processor, text, - [f"Options for MIDI processor '{name}'", "midi_processor.png"])) - - indent += 1 - # Add synth processor - for slot in self.chain.synth_slots: - for processor in slot: - name = processor.get_name() - if not name: - name = "???" - text = " " * indent + "╰━ " + name - res.append((self.processor_options, processor, text, - [f"Options for synth processor '{name}'", "synth_processor.png"])) - indent += 1 - # Build pre-fader audio effects chain - for slot in range(self.chain.fader_pos): - if self.chain.fader_pos <= slot: - break - procs = self.chain.get_processors("Audio Effect", slot) - num_procs = len(procs) - for index, processor in enumerate(procs): - name = processor.get_name() - if index == num_procs - 1: - text = " " * indent + "┗━ " + name - else: - text = " " * indent + "┣━ " + name - res.append((self.processor_options, processor, text, - [f"Options for pre-fader audio processor '{name}'", "audio_processor.png"])) - indent += 1 - # Add FADER mark - if self.chain.audio_thru or self.chain.synth_slots: - res.append((None, None, " " * indent + "┗━ FADER")) - indent += 1 - # Build post-fader audio effects chain - for slot in range(self.chain.fader_pos, len(self.chain.audio_slots)): - procs = self.chain.get_processors("Audio Effect", slot) - num_procs = len(procs) - for index, processor in enumerate(procs): - name = processor.get_name() - if index == num_procs - 1: - text = " " * indent + "┗━ " + name - else: - text = " " * indent + "┣━ " + name - res.append((self.processor_options, processor, text, - [f"Options for post-fader audio processor '{name}'", "audio_processor.png"])) - indent += 1 - return res + # TODO: Catch signal for Audio Recording status change + if self.chain.chain_id == 0 and not zynthian_gui_config.check_wiring_layout(["Z2", "V5"]): + self.list_data.append((None, None, "> Global functions")) + if self.zyngui.state_manager.audio_recorder.status: + self.list_data.append( + (self.zyngui.state_manager.audio_recorder.toggle_recording, None, "■ Stop Audio Recording", ["Stop audio recording", "audio_recorder.png"])) + else: + self.list_data.append( + (self.zyngui.state_manager.audio_recorder.toggle_recording, None, "⬤ Start Audio Recording", ["Start audio recording", "audio_recorder.png"])) + + super().fill_list() def fill_listbox(self): super().fill_listbox() @@ -214,9 +115,7 @@ def fill_listbox(self): i, {'bg': zynthian_gui_config.color_panel_hl, 'fg': zynthian_gui_config.color_tx_off}) def build_view(self): - if self.chain is None: - self.setup() - + self.chain = self.zyngui.chain_manager.active_chain if self.chain is not None: super().build_view() if self.index >= len(self.list_data): @@ -227,85 +126,15 @@ def build_view(self): def select_action(self, i, t='S'): self.index = i - if self.list_data[i][0] is None: - pass - elif self.list_data[i][1] is None: - self.list_data[i][0]() - else: - self.list_data[i][0](self.list_data[i][1], t) - - # Function to handle zynpots value change - # i: Zynpot index [0..n] - # dval: Current value of zyncoder - def zynpot_cb(self, i, dval): - if i == 2: - try: - processor = self.list_data[self.index][1] - if processor is not None and self.zyngui.chain_manager.nudge_processor(self.chain_id, processor, dval < 0): - self.fill_list() - for index, data in enumerate(self.list_data): - if processor == data[1]: - self.select(index) - break - except: - pass # Ignore failure to move processor - else: - super().zynpot_cb(i, dval) - - def arrow_right(self): - chain_keys = self.zyngui.chain_manager.ordered_chain_ids - try: - index = chain_keys.index(self.chain_id) + 1 - except: - index = len(chain_keys) - 1 - if index < len(chain_keys): - # We don't call setup() because it reset the list position (index) - self.chain_id = chain_keys[index] - self.chain = self.zyngui.chain_manager.get_chain(self.chain_id) - self.processor = None - self.set_select_path() - self.update_list() - - def arrow_left(self): - chain_keys = self.zyngui.chain_manager.ordered_chain_ids try: - index = chain_keys.index(self.chain_id) - 1 + self.list_data[i][0]() except: - index = 0 - if index >= 0: - # We don't call setup() because it reset the list position (index) - self.chain_id = chain_keys[index] - self.chain = self.zyngui.chain_manager.get_chain(self.chain_id) - self.processor = None - self.set_select_path() - self.update_list() - - def processor_options(self, subchain, t='S'): - self.zyngui.screens['processor_options'].setup(self.chain_id, subchain) - self.zyngui.show_screen("processor_options") - - def chain_midi_chan(self): - #if self.chain.get_type() == "MIDI Tool": - # chan_all = True - #else: - # chan_all = False - self.zyngui.screens['midi_chan'].set_mode("SET", self.chain.midi_chan, chan_all=True) - self.zyngui.show_screen('midi_chan') - - def chain_midi_cc(self): - self.zyngui.screens['midi_cc'].set_chain(self.chain) - self.zyngui.show_screen('midi_cc') - - def chain_note_range(self): - self.zyngui.screens['midi_key_range'].config(self.chain) - self.zyngui.show_screen('midi_key_range') + pass def midi_learn(self): options = {} options['Enter MIDI-learn'] = "enable_midi_learn" options['Enter Global MIDI-learn'] = "enable_global_midi_learn" - if self.processor: - options[f'Clear {self.processor.name} MIDI-learn'] = "clean_proc" options['Clear chain MIDI-learn'] = "clean_chain" self.zyngui.screens['option'].config( "MIDI-learn", options, self.midi_learn_menu_cb) @@ -313,86 +142,26 @@ def midi_learn(self): def midi_learn_menu_cb(self, options, params): if params == 'enable_midi_learn': - self.zyngui.close_screen() + self.zyngui.replace_screen("control") self.zyngui.cuia_toggle_midi_learn() elif params == 'enable_global_midi_learn': - self.zyngui.close_screen() + self.zyngui.replace_screen("control") self.zyngui.cuia_toggle_midi_learn() self.zyngui.cuia_toggle_midi_learn() - elif params == 'clean_proc': - self.zyngui.show_confirm( - f"Do you want to clean MIDI-learn for ALL controls in processor {self.processor.name}?", self.zyngui.chain_manager.clean_midi_learn, self.processor) elif params == 'clean_chain': self.zyngui.show_confirm( - f"Do you want to clean MIDI-learn for ALL controls in ALL processors within chain {self.chain_id:02d}?", self.zyngui.chain_manager.clean_midi_learn, self.chain_id) - - def chain_midi_routing(self): - self.zyngui.screens['midi_config'].set_chain(self.chain) - self.zyngui.screens['midi_config'].input = False - self.zyngui.show_screen('midi_config') - - def chain_audio_routing(self): - self.zyngui.screens['audio_out'].set_chain(self.chain) - self.zyngui.show_screen('audio_out') - - def audio_options(self): - options = {} - if self.zyngui.state_manager.zynmixer.get_mono(self.chain.mixer_chan): - options['\u2612 Mono'] = ['mono', ["Chain is mono.\n\nLeft and right inputs are summed and fed as mono to left and right outputs", None]] - else: - options['\u2610 Mono'] = ['mono', ["Chain is stereo.\n\nLeft input feeds left output and right input feeds right output.", None]] - if self.zyngui.state_manager.zynmixer.get_phase(self.chain.mixer_chan): - options['\u2612 Phase reverse'] = ['phase', ["Chain is phase reversed.\n\nRight output is inverted, making it 180° out of phase with its input.", None]] - else: - options['\u2610 Phase reverse'] = ['phase', ["Chain is not phase reversed.\n\nLeft and right inputs feed left and right outputs without phase modification.", None]] - if self.zyngui.state_manager.zynmixer.get_ms(self.chain.mixer_chan): - options['\u2612 M+S'] = ['ms', ["Mid/Side mode is enabled.\n\nLeft output carries the 'Mid' signal. Right output carries the 'Side' signal.", None]] - else: - options['\u2610 M+S'] = ['ms', ["Mid/Side mode is disabled.\n\nLeft and right inputs feed left and right outputs.", None]] - - self.zyngui.screens['option'].config( - "Mixer options", options, self.audio_menu_cb, False, False, None) - self.zyngui.show_screen('option') - - def audio_menu_cb(self, options, params): - if params == 'mono': - self.zyngui.state_manager.zynmixer.toggle_mono( - self.chain.mixer_chan) - elif params == 'phase': - self.zyngui.state_manager.zynmixer.toggle_phase( - self.chain.mixer_chan) - elif params == 'ms': - self.zyngui.state_manager.zynmixer.toggle_ms(self.chain.mixer_chan) - self.audio_options() - - def chain_audio_capture(self): - self.zyngui.screens['audio_in'].set_chain(self.chain) - self.zyngui.show_screen('audio_in') - - def chain_midi_capture(self): - self.zyngui.screens['midi_config'].set_chain(self.chain) - self.zyngui.screens['midi_config'].input = True - self.zyngui.show_screen('midi_config') - - def toggle_recording(self): - if self.processor and self.processor.engine and self.processor.engine.name == 'AudioPlayer': - self.zyngui.state_manager.audio_recorder.toggle_recording( - self.processor) - else: - self.zyngui.state_manager.audio_recorder.toggle_recording( - self.zyngui.state_manager.audio_player) - self.fill_list() + f"Do you want to clean MIDI-learn for ALL controls in ALL processors within chain {self.chain.chain_id:02d}?", self.zyngui.chain_manager.clean_midi_learn, self.chain.chain_id) def move_chain(self): - self.zyngui.screens["audio_mixer"].moving_chain = True - self.zyngui.show_screen_reset('audio_mixer') + self.zyngui.screens["chain_manager"].start_moving_chain() + self.zyngui.show_screen_reset('chain_manager') def rename_chain(self): self.zyngui.show_keyboard(self.do_rename_chain, self.chain.title) def do_rename_chain(self, title): - self.chain.title = title - self.zyngui.show_screen_reset('audio_mixer') + self.zyngui.chain_manager.set_chain_title(self.chain.chain_id, title) + self.zyngui.show_screen_reset('chain_manager') def export_chain(self): options = {} @@ -418,7 +187,7 @@ def confirm_export_chain(self, title): self.do_export_chain(path) def do_export_chain(self, path): - self.zyngui.state_manager.export_chain(path, self.chain_id) + self.zyngui.state_manager.export_chain(path, self.chain.chain_id) # Remove submenu @@ -426,9 +195,9 @@ def remove_cb(self): options = {} if self.chain.synth_slots and self.chain.get_processor_count("MIDI Tool"): options['Remove All MIDI-FXs'] = "midifx" - if self.chain.get_processor_count("Audio Effect"): + if self.chain.get_processor_count("Audio Effect") > 1: options['Remove All Audio-FXs'] = "audiofx" - if self.chain_id != 0: + if self.chain.chain_id != 0: options['Remove Chain'] = "chain" self.zyngui.screens['option'].config( "Remove...", options, self.remove_all_cb) @@ -447,18 +216,46 @@ def remove_chain(self, params=None): "Do you really want to remove this chain?", self.chain_remove_confirmed) def chain_remove_confirmed(self, params=None): - self.zyngui.chain_manager.remove_chain(self.chain_id) - self.zyngui.show_screen_reset('audio_mixer') + self.zyngui.chain_manager.remove_chain(self.chain.chain_id) + self.zyngui.show_screen_reset('chain_manager') + + def remove_all(self, t='S'): + self.zyngui.show_confirm( + "Do you really want to remove ALL chains & sequences?", self.remove_all_confirmed) + + def remove_all_confirmed(self, params=None): + self.index = 0 + self.zyngui.clean_all() + self.zyngui.show_screen_reset('chain_manager') + + def remove_chains(self, t='S'): + self.zyngui.show_confirm( + "Do you really want to remove ALL chains?", self.remove_chains_confirmed) + + def remove_chains_confirmed(self, params=None): + self.index = 0 + self.zyngui.clean_chains() + self.zyngui.show_screen_reset('chain_manager') + + def remove_sequences(self, t='S'): + self.zyngui.show_confirm( + "Do you really want to remove ALL sequences?", self.remove_sequences_confirmed) + + def remove_sequences_confirmed(self, params=None): + self.index = 0 + self.zyngui.clean_sequences() + self.zyngui.show_screen_reset('chain_manager') + + def insert_chain(self, params=None): + pos = self.zyngui.chain_manager.get_chain_index(self.chain.chain_id) + self.zyngui.screens["add_chain"].set_chain_pos(pos) + self.zyngui.show_screen("add_chain") # FX-Chain management def audiofx_add(self): self.zyngui.modify_chain( - {"type": "Audio Effect", "chain_id": self.chain_id, "post_fader": False}) - - def postfader_add(self): - self.zyngui.modify_chain( - {"type": "Audio Effect", "chain_id": self.chain_id, "post_fader": True}) + {"type": "Audio Effect", "chain_id": self.chain.chain_id}) def remove_all_audiofx(self): self.zyngui.show_confirm( @@ -466,8 +263,10 @@ def remove_all_audiofx(self): def remove_all_procs_cb(self, type=None): for processor in self.chain.get_processors(type): + if processor.eng_code in ["MI", "MR"]: + continue self.zyngui.chain_manager.remove_processor( - self.chain_id, processor) + self.chain.chain_id, processor) self.build_view() self.show() @@ -475,7 +274,7 @@ def remove_all_procs_cb(self, type=None): def midifx_add(self): self.zyngui.modify_chain( - {"type": "MIDI Tool", "chain_id": self.chain_id}) + {"type": "MIDI Tool", "chain_id": self.chain.chain_id}) def remove_all_midifx(self): self.zyngui.show_confirm( diff --git a/zyngui/zynthian_gui_config.py b/zyngui/zynthian_gui_config.py index c871c24e2..8c9dd6b62 100644 --- a/zyngui/zynthian_gui_config.py +++ b/zyngui/zynthian_gui_config.py @@ -373,8 +373,8 @@ def config_zyntof(): # Setup MIDI options def set_midi_config(): global active_midi_channel, midi_prog_change_zs3, midi_bank_change, midi_fine_tuning - global midi_usb_by_port, transport_clock_source, transport_analog_clock_divisor - global midi_filter_rules, midi_network_enabled, midi_rtpmidi_enabled, midi_netump_enabled + global midi_usb_by_port, transport_clock_source, midi_filter_rules, midi_chanpress_cc + global midi_network_enabled, midi_rtpmidi_enabled, midi_netump_enabled global midi_touchosc_enabled, bluetooth_enabled, ble_controller, midi_aubionotes_enabled # MIDI options @@ -390,12 +390,12 @@ def set_midi_config(): bluetooth_enabled = get_env_int('ZYNTHIAN_MIDI_BLE_ENABLED', 0) ble_controller = os.environ.get('ZYNTHIAN_MIDI_BLE_CONTROLLER', "") midi_aubionotes_enabled = get_env_int('ZYNTHIAN_MIDI_AUBIONOTES_ENABLED', 0) - transport_clock_source = get_env_int('ZYNTHIAN_MIDI_TRANSPORT_CLOCK_SOURCE', 0) - transport_analog_clock_divisor = get_env_int('ZYNTHIAN_MIDI_TRANSPORT_ANALOG_CLOCK_DIVISOR', 1) + transport_clock_source = os.environ.get('ZYNTHIAN_MIDI_TRANSPORT_CLOCK_SOURCE', "Internal") # Filter Rules midi_filter_rules = os.environ.get('ZYNTHIAN_MIDI_FILTER_RULES', "") midi_filter_rules = midi_filter_rules.replace("\\n", "\n") + midi_chanpress_cc = get_env_int('ZYNTHIAN_MIDI_CHANPRESS_CC', 0) # Setup MIDI Master Channel options @@ -508,7 +508,7 @@ def get_external_storage_dirs(exdpath): color_tx_off = os.environ.get('ZYNTHIAN_UI_COLOR_TX_OFF', "#e0e0e0") color_on = os.environ.get('ZYNTHIAN_UI_COLOR_ON', "#ff0000") color_off = os.environ.get('ZYNTHIAN_UI_COLOR_OFF', "#5a626d") -color_hl = os.environ.get('ZYNTHIAN_UI_COLOR_HL', "#00b000") +color_hl = os.environ.get('ZYNTHIAN_UI_COLOR_HL', "#00c000") color_ml = os.environ.get('ZYNTHIAN_UI_COLOR_ML', "#f0f000") color_low_on = os.environ.get('ZYNTHIAN_UI_COLOR_LOW_ON', "#b00000") color_panel_bg = os.environ.get('ZYNTHIAN_UI_COLOR_PANEL_BG', "#3a424d") @@ -518,6 +518,7 @@ def get_external_storage_dirs(exdpath): color_alt = os.environ.get('ZYNTHIAN_UI_COLOR_ALT', "#ff00ff") color_alt2 = os.environ.get('ZYNTHIAN_UI_COLOR_ALT2', "#ff9000") color_error = os.environ.get('ZYNTHIAN_UI_COLOR_ERROR', "#ff0000") +color_warn = os.environ.get('ZYNTHIAN_UI_COLOR_WARN', "#ff9000") # Color Scheme color_panel_bd = color_bg @@ -534,6 +535,7 @@ def get_external_storage_dirs(exdpath): color_status_play_midi = color_alt color_status_play_seq = color_alt2 color_status_error = color_error +color_status_warn = color_warn # ------------------------------------------------------------------------------ # Font Family @@ -609,6 +611,7 @@ def get_external_storage_dirs(exdpath): snapshot_mixer_settings = get_env_int('ZYNTHIAN_UI_SNAPSHOT_MIXER_SETTINGS', 0) show_cpu_status = get_env_int('ZYNTHIAN_UI_SHOW_CPU_STATUS', 0) visible_mixer_strips = get_env_int('ZYNTHIAN_UI_VISIBLE_MIXER_STRIPS', 0) +visible_launchers = get_env_int('ZYNTHIAN_UI_VISIBLE_LAUNCHERS', 8) ctrl_graph = get_env_int('ZYNTHIAN_UI_CTRL_GRAPH', 1) control_test_enabled = get_env_int('ZYNTHIAN_UI_CONTROL_TEST_ENABLED', 0) power_save_secs = 60 * get_env_int('ZYNTHIAN_UI_POWER_SAVE_MINUTES', 60) @@ -647,54 +650,115 @@ def get_external_storage_dirs(exdpath): # Sequence states # ------------------------------------------------------------------------------ -PAD_COLOUR_DISABLED = '#303030' -PAD_COLOUR_DISABLED_LIGHT = '#505050' -PAD_COLOUR_STARTING = '#ffbb00' -PAD_COLOUR_PLAYING = '#00d000' -PAD_COLOUR_STOPPING = 'red' -PAD_COLOUR_GROUP = [ - '#662426', # Red Granate - '#3c6964', # Blue Aguamarine - '#4d6817', # Green Pistacho - '#664980', # Lila - '#4C709A', # Mid Blue - '#4C94CC', # Sky Blue - '#006000', # Dark Green - '#B7AA5E', # Ocre - '#996633', # Maroon - '#746360', # Dark Grey - '#D07272', # Pink - '#000060', # Blue sat. - '#048C8C', # Turquesa - '#f46815', # Orange - '#BF9C7C', # Light Maroon - '#56A556', # Light Green - '#FC6CB4', # 7 medium - '#CC8464', # 8 medium - '#4C94CC', # 9 medium - '#B454CC', # 10 medium - '#B08080', # 11 medium - '#0404FC', # 12 light - '#9EBDAC', # 13 light - '#FF13FC', # 14 light - '#3080C0', # 15 light - '#9C7CEC' # 16 light +PAD_COLOUR_DISABLED = '#505050' +PAD_COLOUR_STATE_DISABLED = '#A0A0A0' +PAD_COLOUR_EMPTY = '#707070' +PAD_COLOUR_STARTING = '#FFBB00' +PAD_COLOUR_PLAYING = '#00FF00' +PAD_COLOUR_STOPPING = '#FF0000' +PAD_COLOUR_STOPPED = '#E0E0E0' +PAD_COLOUR_PHRASE = '#707070' +LAUNCHER_COLOUR = [ + # MIDI Channels 1..16 (offset 0..15) + {"rgb": "#0000FF", "launchpad": 79, "apc": 45}, #1:blue + {"rgb": "#BBBB00", "launchpad": 13, "apc": 13}, #2:yellow + {"rgb": "#FF00FF", "launchpad": 53, "apc": 53}, #3:magenta + {"rgb": "#23C497", "launchpad": 33, "apc": 33}, #4:lime green + {"rgb": "#FF5400", "launchpad": 9, "apc": 60}, #5:orange + {"rgb": "#874CFF", "launchpad": 49, "apc": 80}, #6:deep purple + {"rgb": "#FF4C87", "launchpad": 57, "apc": 57}, #7:hot pink + {"rgb": "#2DB7CE", "launchpad": 37, "apc": 37}, #8:cyan + {"rgb": "#D2C7D4", "launchpad": 2, "apc": 1}, #9:grey + {"rgb": "#C9A869", "launchpad": 125, "apc": 127}, #10:light brown + {"rgb": "#7BC783", "launchpad": 19, "apc": 16}, #11:turquise + {"rgb": "#EB8895", "launchpad": 4, "apc": 4}, #12:pink + {"rgb": "#CA92d4", "launchpad": 70, "apc": 69}, #13:light purple + {"rgb": "#4CFFB7", "launchpad": 24, "apc": 20}, #14:green-blue + {"rgb": "#3F94A2", "launchpad": 42, "apc": 65}, #15:teal + {"rgb": "#F5B169", "launchpad": 126, "apc": 10}, #16:light orange + # Clip launchers 1..16 (offset 16..31) + {"rgb": "#F5B169", "launchpad": 126, "apc": 10}, #17:light orange + {"rgb": "#3F94A2", "launchpad": 42, "apc": 65}, #18:teal + {"rgb": "#4CFFB7", "launchpad": 24, "apc": 20}, #19:green-blue + {"rgb": "#CA92d4", "launchpad": 70, "apc": 69}, #20:light purple + {"rgb": "#EB8895", "launchpad": 4, "apc": 4}, #21:pink + {"rgb": "#7BC783", "launchpad": 19, "apc": 16}, #22:turquise + {"rgb": "#C9A869", "launchpad": 125, "apc": 127}, #23:light brown + {"rgb": "#D2C7D4", "launchpad": 2, "apc": 1}, #24:grey + {"rgb": "#2DB7CE", "launchpad": 37, "apc": 37}, #25:cyan + {"rgb": "#FF4C87", "launchpad": 57, "apc": 57}, #26:hot pink + {"rgb": "#874CFF", "launchpad": 49, "apc": 80}, #27:deep purple + {"rgb": "#FF5400", "launchpad": 9, "apc": 60}, #28:orange + {"rgb": "#23C497", "launchpad": 33, "apc": 33}, #29:lime green + {"rgb": "#FF00FF", "launchpad": 53, "apc": 53}, #30:magenta + {"rgb": "#BBBB00", "launchpad": 13, "apc": 13}, #31:yellow + {"rgb": "#0000FF", "launchpad": 79, "apc": 45}, #32:blue + # Main / phrase launchers (offset 32) + {"rgb": "#707070", "launchpad": 1, "apc": 1} #33:grey ] +#TODO: Choose clip launcher colours (currently just reversed 1-16) +LAUNCHER_PLAYING_COLOUR = {"rgb": "#009000", "launchpad": 21, "apc": 87} #green +LAUNCHER_STARTING_COLOUR = {"rgb": "#009000", "launchpad": 21, "apc": 87} #green +LAUNCHER_STOPPING_COLOUR = {"rgb": "#D00000", "launchpad": 5, "apc": 72} #red + +def get_color_relux(hex_color): + if len(hex_color) != 7: + raise Exception("Passed %s into get_color_relux2(), needs to be in #RRGGBB format." % hex_color) + R, G, B = [int(hex_color[x:x + 2], 16) for x in [1, 3, 5]] + if R <= 10: + Rg = R / 3294.0 + else: + Rg = (R / 269.0 + 0.0513) ** 2.4 + if G <= 10: + Gg = G / 3294.0 + else: + Gg = (G / 269.0 + 0.0513) ** 2.4 + if B <= 10: + Bg = B / 3294.0 + else: + Bg = (B / 269.0 + 0.0513) ** 2.4 + return 0.2126 * Rg + 0.7152 * Gg + 0.0722 * Bg + +def get_color_lux(hex_color): + if len(hex_color) != 7: + raise Exception("Passed %s into get_color_relux(), needs to be in #RRGGBB format." % hex_color) + R, G, B = [int(hex_color[x:x + 2], 16) for x in [1, 3, 5]] + # Counting the perceptive luminance - human eye favors green color... + return (0.299 * R + 0.587 * G + 0.114 * B) / 255.0; + +def get_contrast_ratio(hex_color1, hex_color2): + L1 = get_color_relux(hex_color1) + L2 = get_color_relux(hex_color2) + if L1 > L2: + return (L1 + 0.05) / (L2 + 0.05) + else: + return (L2 + 0.05) / (L1 + 0.05) def color_variant(hex_color, brightness_offset=1): """ takes a color like #87c95f and produces a lighter or darker variant """ if len(hex_color) != 7: - raise Exception("Passed %s into color_variant(), needs to be in #87c95f format." % hex_color) - rgb_hex = [hex_color[x:x + 2] for x in [1, 3, 5]] - new_rgb_int = [int(hex_value, 16) + brightness_offset for hex_value in rgb_hex] + raise Exception("Passed %s into color_variant(), needs to be in #RRGGBB format." % hex_color) + rgb_int = [int(hex_color[x:x + 2], 16) for x in [1, 3, 5]] + new_rgb_int = [val + brightness_offset for val in rgb_int] # make sure new values are between 0 and 255 - new_rgb_int = [min([255, max([0, i])]) for i in new_rgb_int] + new_rgb_int = [min(255, max(0, i)) for i in new_rgb_int] # hex() produces "0x88", we want just "88" return "#" + "".join([hex(i)[2:].zfill(2) for i in new_rgb_int]) +def color_scale(hex_color, brightness_scale=1.0): + """ takes a color like #87c95f and produces a lighter or darker variant """ + if len(hex_color) != 7: + raise Exception("Passed %s into color_scale(), needs to be in #87c95f format." % hex_color) + rgb_int = [int(hex_color[x:x + 2], 16) for x in [1, 3, 5]] + new_rgb_int = [int(val * brightness_scale) for val in rgb_int] + # make sure new values are between 0 and 255 + new_rgb_int = [min(255, i) for i in new_rgb_int] + # hex() produces "0x88", we want just "88" + return "#" + "".join([hex(i)[2:].zfill(2) for i in new_rgb_int]) -PAD_COLOUR_GROUP_LIGHT = [color_variant(c, 40) for c in PAD_COLOUR_GROUP] +for i, value in enumerate(LAUNCHER_COLOUR): + LAUNCHER_COLOUR[i]["rgb_light"] = color_variant(value["rgb"], 40) # ------------------------------------------------------------------------------ # X11 Related Stuff @@ -731,7 +795,7 @@ def color_variant(hex_color, brightness_offset=1): display_height = 240 # Global font size - font_size = get_env_int('ZYNTHIAN_UI_FONT_SIZE', None) + font_size = get_env_int('ZYNTHIAN_UI_FONT_SIZE', 16) if not font_size: font_size = int(display_width / 40) diff --git a/zyngui/zynthian_gui_control.py b/zyngui/zynthian_gui_control.py index c2f99ab48..1a45211f8 100644 --- a/zyngui/zynthian_gui_control.py +++ b/zyngui/zynthian_gui_control.py @@ -64,11 +64,10 @@ def __init__(self, selcap='Controllers'): self.screen_name = None self.screen_type = None self.screen_title = None - self.screen_processor = None # TODO: Refactor self.buttonbar_config = [ ("arrow_left", '<< Prev'), - ("zynswitch 1,S", 'Preset'), + ("zynswitch 0,B", 'Preset'), ("zynswitch 3,S", 'Pages'), ("arrow_right", 'Next >>') ] @@ -92,35 +91,56 @@ def build_view(self): #curproc = self.zyngui.get_current_processor() super().build_view() if not self.shown: + zynsigman.register_queued(zynsigman.S_STATE_MAN, self.state_manager.SS_LOAD_ZS3, self.cb_load_zs3) + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.cb_set_active_chain) + zynsigman.register_queued(zynsigman.S_MIDI, zynsigman.SS_MIDI_CC, self.cb_midi_pc) zynsigman.register(zynsigman.S_MIDI, zynsigman.SS_MIDI_CC, self.cb_midi_cc) - zynsigman.register(zynsigman.S_MIDI, zynsigman.SS_MIDI_PC, self.cb_midi_pc) if zynthian_gui_config.enable_touch_navigation: - zynsigman.register(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_SIDEBAR, self.cb_show_sidebar) - zynsigman.register(zynsigman.S_GUI, zynsigman.SS_GUI_CONTROL_MODE, self.cb_control_mode) + zynsigman.register_queued(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_SIDEBAR, self.cb_show_sidebar) + zynsigman.register_queued(zynsigman.S_GUI, zynsigman.SS_GUI_CONTROL_MODE, self.cb_control_mode) #self.set_mode_control() return True def hide(self): if self.shown: self.exit_midi_learn() + zynsigman.unregister(zynsigman.S_STATE_MAN, self.state_manager.SS_LOAD_ZS3, self.cb_load_zs3) + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.cb_set_active_chain) + zynsigman.unregister(zynsigman.S_MIDI, zynsigman.SS_MIDI_CC, self.cb_midi_pc) zynsigman.unregister(zynsigman.S_MIDI, zynsigman.SS_MIDI_CC, self.cb_midi_cc) - zynsigman.unregister(zynsigman.S_MIDI, zynsigman.SS_MIDI_PC, self.cb_midi_pc) if zynthian_gui_config.enable_touch_navigation: zynsigman.unregister(zynsigman.S_GUI, zynsigman.SS_GUI_SHOW_SIDEBAR, self.cb_show_sidebar) zynsigman.unregister(zynsigman.S_GUI, zynsigman.SS_GUI_CONTROL_MODE, self.cb_control_mode) super().hide() - def cb_midi_pc(self, izmip, chan, num): + def cb_set_active_chain(self, active_chain_id): """Handle MIDI_PC signal - izmip : MIDI input device index - chan : MIDI channel - num : CC number + active_chain_id : active chain id + """ + + # Refresh control screen after changing active chain + self.zyngui.chain_control() + + def cb_load_zs3(self, zs3_id): + """Handle LOAD_ZS3 signal + + zs3_id : ID of loaded zs3 """ # Refresh control screen after loading ZS3 self.zyngui.chain_control() + def cb_midi_pc(self, izmip, chan, num): + """Handle MIDI_PC signal + + """ + + if not zynthian_gui_config.midi_prog_change_zs3 and self.curproc and \ + self.curproc.midi_chan is not None and self.curproc.midi_chan == chan: + # Refresh control screen after changing preset with program change + self.zyngui.chain_control() + def show_sidebar(self, show): self.sidebar_shown = show for zctrl in self.zgui_controllers: @@ -148,10 +168,20 @@ def configure_processors(self, curproc=None): if not curproc: self.processors = [] else: - if curproc in (self.zyngui.state_manager.alsa_mixer_processor, self.zyngui.state_manager.audio_player): + if curproc and curproc.id < 0: self.processors = [curproc] else: - self.processors = self.zyngui.chain_manager.get_processors(curproc.chain_id) + self.processors = self.chain_manager.get_processors(curproc.chain_id) + try: + self.init_buttonbar(curproc.engine.buttonbar_config) + except: + self.init_buttonbar([ + ("arrow_left", '<< Prev'), + ("zynswitch 0,B", 'Preset'), + ("zynswitch 3,S", 'Pages'), + ("arrow_right", 'Next >>') + ]) + def fill_list(self): self.list_data = [] @@ -162,6 +192,7 @@ def fill_list(self): if not self.processors: self.list_data.append((None, None, "NO PROCESSORS!")) else: + i = 0 # Chain controllers => favorite processor controllers # Some processors have no chain => I.e. global audio player if self.processors[0].chain: @@ -169,6 +200,7 @@ def fill_list(self): if chain_zctrls: self.list_data.append((None, None, "> CHAIN")) j = 0 + i += 1 page_zctrls = [] for zctrl in chain_zctrls: page_zctrls.append(zctrl) @@ -176,15 +208,19 @@ def fill_list(self): self.list_data.append((f"CHAIN_{j}", -1, f"Controllers {j + 1}", self.processors[0], j, page_zctrls)) page_zctrls = [] j += 1 + i += 1 if len(page_zctrls) > 0: self.list_data.append((f"CHAIN_{j}", -1, f"Controllers {j + 1}", self.processors[0], j, page_zctrls)) + i += 1 # Processor Controllers - i = 0 for processor in self.processors: j = 0 screen_list = processor.get_ctrl_screens() procname = processor.engine.name.split('/')[-1] self.list_data.append((None, None, f"> {procname}")) + i += 1 + if processor == curproc: + self.index = i + curproc.get_current_screen_index() for cscr in screen_list: try: self.list_data.append((screen_list[cscr][0].group_symbol, i, cscr, processor, j)) @@ -192,7 +228,6 @@ def fill_list(self): j += 1 except Exception as e: logging.error(f"Can't add control page '{cscr}' for processor '{procname}' => {e}") - self.index = curproc.get_current_screen_index() self.get_screen_info() super().fill_list() @@ -208,7 +243,6 @@ def get_screen_info(self): if self.screen_info: if len(self.screen_info) >= 5: self.screen_title = self.screen_info[2] - self.screen_processor = self.screen_info[3] self.screen_type = None return True else: @@ -216,32 +250,21 @@ def get_screen_info(self): # logging.info("Can't get screen info!!") self.screen_title = "" self.screen_type = None - self.screen_processor = self.zyngui.get_current_processor() return False def get_screen_type(self): - """ - if self.screen_title: - # Some heuristics to detect ADSR control screens ... - # TODO: This should be improved by marking ADSR groups!! - if " Env" in self.screen_title or " ADSR" in self.screen_title or\ - ("attack" in self.zcontrollers[0].name.lower() and - "decay" in self.zcontrollers[1].name.lower() and - "sustain" in self.zcontrollers[2].name.lower() and - "release" in self.zcontrollers[3].name.lower()): - self.screen_type = "envelope" - """ self.widget_zctrl = None for zctrl in self.zcontrollers: if hasattr(zctrl, "envelope"): self.screen_type = "envelope" break - if zctrl.is_path and (set(zctrl.path_file_types) & {"wav", "aiff", "flac", "mp3", "ogg"}): + elif hasattr(zctrl, "filter"): + self.screen_type = "filter" + break + elif zctrl.is_path and (set(zctrl.path_file_types) & {"wav", "aiff", "flac", "mp3", "ogg"}): self.screen_type = "audio_file" self.widget_zctrl = zctrl break - else: - self.screen_type = None return self.screen_type def fill_listbox(self): @@ -343,7 +366,7 @@ def purge_widgets(self): proc_id = int(parts[1]) except: continue - if proc_id not in self.zyngui.chain_manager.processors: + if proc_id not in self.chain_manager.processors: logging.debug(f"Deleting orphaned widget: {k}") if self.widgets[k] == self.current_widget: self.hide_widgets() @@ -355,26 +378,21 @@ def set_current_widget(self, widget): if widget is not None and widget == self.current_widget: return self.current_widget = widget - # Clean dynamic CUIA methods from widgets - for fn in dir(self): - if fn.startswith('cuia_') or fn == 'update_wsleds': - delattr(self, fn) - # logging.debug(f"DELATTR {fn}") - # Create new dynamix CUIA methods + + def update_wsleds(self, leds): if self.current_widget: - for fn in dir(self.current_widget): - if fn.startswith('cuia_') or fn == 'update_wsleds': - func = getattr(self.current_widget, fn) - if callable(func): - setattr(self, fn, func) - # logging.debug(f"SETATTR {fn}") + try: + self.current_widget.update_wsleds(leds) + except (AttributeError, TypeError): + pass def set_controller_screen(self): # Get screen info if self.get_screen_info(): - if self.screen_processor: - self.zyngui.chain_manager.get_active_chain().set_current_processor(self.screen_processor) - self.zyngui.current_processor = self.screen_processor + try: + self.zyngui.set_current_processor(self.screen_info[3]) + except Exception as e: + logging.warning(f"Failed to set current processor {e}") # Get controllers for the current screen # Chain controllers @@ -382,14 +400,12 @@ def set_controller_screen(self): self.zcontrollers = self.screen_info[5] # Processor controllers else: - self.zyngui.get_current_processor().set_current_screen_index(self.index) - self.zcontrollers = self.screen_processor.get_ctrl_screen(self.screen_title) - - # Show the widget for the current processor - if self.mode == 'control': - self.get_screen_type() - self.show_widget(self.screen_processor) - + self.zyngui.get_current_processor().set_current_screen_index(self.screen_info[4]) + self.zcontrollers = self.zyngui.get_current_processor().get_ctrl_screen(self.screen_title) + # Show the widget for the current processor (NOT for chain controllers pages!) + if self.mode == 'control': + self.get_screen_type() + self.show_widget(self.zyngui.get_current_processor()) else: self.zcontrollers = [] self.screen_title = "" @@ -458,6 +474,7 @@ def set_mode_control(self): self.zgui_controllers[i].enable() self.set_select_path() self.set_button_status(2, False) + self.select() def previous_page(self, wrap=False): i = self.index - 1 @@ -495,17 +512,17 @@ def arrow_down(self): def arrow_right(self): self.exit_midi_learn() - self.zyngui.chain_manager.next_chain() + self.chain_manager.next_chain() self.zyngui.chain_control() def arrow_left(self): self.exit_midi_learn() - self.zyngui.chain_manager.previous_chain() + self.chain_manager.previous_chain() self.zyngui.chain_control() def rotate_chain(self): self.exit_midi_learn() - self.zyngui.chain_manager.rotate_chain() + self.chain_manager.rotate_chain() self.zyngui.chain_control() # Function to handle *all* switch presses. @@ -528,14 +545,14 @@ def switch(self, swi, t='S'): if t == 'S': self.rotate_chain() return True + elif t == "B": + self.zyngui.cuia_bank_preset() + return True elif swi == 1: if t == 'S': if self.back_action(): return True - elif not self.zyngui.is_shown_alsa_mixer(): - self.zyngui.cuia_bank_preset() - return True elif t == 'B': self.back_action() return False @@ -549,6 +566,20 @@ def switch(self, swi, t='S'): self.midi_unlearn_action() return True + def cuia_v5_zynpot_switch(self, params): + i = params[0] + t = params[1].upper() + if t == 'S': + if self.mode == 'select': + self.switch_select(t) + else: + self.toggle_midi_learn(i) + return True + elif t == 'B': + self.midi_learn_options(i) + return True + return False + def switch_select(self, t): if t == 'S': if self.mode == 'control': @@ -558,13 +589,14 @@ def switch_select(self, t): logging.debug("MODE CONTROL!!") self.set_mode_control() elif t == 'B': - self.zyngui.cuia_chain_options() + self.show_menu() return True def select(self, index=None, set_zctrl=True): super().select(index, set_zctrl) #if self.mode == 'select': self.set_controller_screen() + self.set_select_path() #self.set_selector_screen() def zynpot_abs(self, i, val): @@ -618,12 +650,12 @@ def plot_zctrls(self, force=False): # -------------------------------------------------------------------------- def show_menu(self): - self.zyngui.cuia_chain_options() + zynthian_gui_config.zyngui.show_screen('chain_manager') def toggle_menu(self): if self.shown: self.show_menu() - elif self.zyngui.current_screen.endswith("_options"): + elif self.zyngui.get_current_screen().endswith("_options"): self.zyngui.close_screen() # -------------------------------------------------------------------------- @@ -694,9 +726,9 @@ def midi_learn(self, i, mlmode=MIDI_LEARNING_CHAIN): def midi_learn_bind(self, zmip, chan, midi_cc): if self.midi_learning == MIDI_LEARNING_CHAIN: - self.zyngui.chain_manager.add_midi_learn(chan, midi_cc, self.zyngui.state_manager.get_midi_learn_zctrl()) + self.chain_manager.add_midi_learn(chan, midi_cc, self.zyngui.state_manager.get_midi_learn_zctrl()) elif self.midi_learning == MIDI_LEARNING_GLOBAL: - self.zyngui.chain_manager.add_midi_learn(chan, midi_cc, self.zyngui.state_manager.get_midi_learn_zctrl(), zmip) + self.chain_manager.add_midi_learn(chan, midi_cc, self.zyngui.state_manager.get_midi_learn_zctrl(), zmip) self.exit_midi_learn() def cb_midi_cc(self, izmip, chan, num, val): @@ -716,9 +748,9 @@ def cb_midi_cc(self, izmip, chan, num, val): def midi_unlearn(self, param=None): if param: - self.zyngui.chain_manager.clean_midi_learn(param) + self.chain_manager.clean_midi_learn(param) else: - self.zyngui.chain_manager.clean_midi_learn(self.zyngui.get_current_processor()) + self.chain_manager.clean_midi_learn(self.zyngui.get_current_processor()) self.refresh_midi_bind() def midi_unlearn_action(self): @@ -755,7 +787,7 @@ def midi_learn_options(self, i, keep_selection=False, unlearn_only=False): self.zyngui.show_screen('option') return - ml = self.zyngui.chain_manager.get_midi_learn_from_zctrl(zctrl, abs=True, chain=True, zynstep=False) + ml = self.chain_manager.get_midi_learn_from_zctrl(zctrl, abs=True, chain=True, zynstep=False) if not unlearn_only: title = f"Control options: {zctrl.name}" @@ -801,16 +833,19 @@ def midi_learn_options(self, i, keep_selection=False, unlearn_only=False): match zctrl.midi_cc_mode: case -1: options["Relative Mode learning..."] = i + options["CC Value Range"] = i case 0: if zctrl.range_reversed: options["Absolute Reverse"] = i else: options["Absolute Mode"] = i + options["CC Value Range"] = i case _: - options[f"Relative Mode {zctrl.midi_cc_mode}"] = i - options[f"Chain learn ..."] = i - options[f"Global learn ..."] = i - zynstep_ml = self.zyngui.chain_manager.get_midi_learn_from_zctrl(zctrl, abs=False, chain=False, zynstep=True) + options[f"Relative Mode {zctrl.midi_cc_mode}"] = i + if zctrl.processor: + options[f"Chain learn..."] = i + options[f"Global learn..."] = i + zynstep_ml = self.chain_manager.get_midi_learn_from_zctrl(zctrl, abs=False, chain=False, zynstep=True) if zynstep_ml: ccnum = zynstep_ml[0] & 0x7f else: @@ -845,7 +880,6 @@ def midi_learn_options(self, i, keep_selection=False, unlearn_only=False): logging.error(f"Can't show control options => {e}") def midi_learn_options_cb(self, option, param): - parts = option.split(" ") if option[2:] == "Chain Controller": if self.processors[0].chain: self.processors[0].chain.toggle_zctrl(param) @@ -855,66 +889,71 @@ def midi_learn_options_cb(self, option, param): elif option == "Clear": param.set_value("") self.select() - elif parts[1] == "X-axis": - zctrl = self.zgui_controllers[param].zctrl - if self.zyngui.state_manager.zctrl_x == zctrl: - self.zyngui.state_manager.zctrl_x = None - else: - self.zyngui.state_manager.zctrl_x = zctrl - if self.zyngui.state_manager.zctrl_y == zctrl: - self.zyngui.state_manager.zctrl_y = None - #self.refresh_midi_bind() - self.midi_learn_options(param, keep_selection=True) - elif parts[1] == "Y-axis": - zctrl = self.zgui_controllers[param].zctrl - if self.zyngui.state_manager.zctrl_y == zctrl: - self.zyngui.state_manager.zctrl_y = None - else: - self.zyngui.state_manager.zctrl_y = zctrl - if self.zyngui.state_manager.zctrl_x == zctrl: - self.zyngui.state_manager.zctrl_x = None - #self.refresh_midi_bind() - self.midi_learn_options(param, keep_selection=True) - elif parts[0] == "Chain": - self.midi_learn(param, MIDI_LEARNING_CHAIN) - elif parts[0] == "Global": - self.midi_learn(param, MIDI_LEARNING_GLOBAL) - elif parts[0] == "ZynStep": - try: - ccnum = int(parts[2][1:-1]) - except: - ccnum = None - self.zyngui.screens['midi_cc_single'].config(self.zynstep_midi_cc_cb, ccnum, param) - self.zyngui.show_screen('midi_cc_single') - elif parts[0] == "Unlearn": - if param: - self.midi_unlearn(param) - else: - self.midi_unlearn_action() - elif parts[1] == "Momentary": - if parts[0] == '\u2612': - self.zgui_controllers[param].zctrl.midi_cc_momentary_switch = 0 - else: - self.zgui_controllers[param].zctrl.midi_cc_momentary_switch = 1 - self.midi_learn_options(param, keep_selection=True) - elif parts[1] == "Debounce": - if parts[0] == '\u2612': - self.zgui_controllers[param].zctrl.midi_cc_debounce = 0 - else: - self.zgui_controllers[param].zctrl.midi_cc_debounce = 1 - self.midi_learn_options(param, keep_selection=True) - elif parts[0] in ["Relative", "Absolute"]: - options = { - "Absolute Mode": (param, 0), - "Absolute Reverse": (param, 0), - "Relative Mode 1": (param, 1), - "Relative Mode 2": (param, 2), - "Relative Mode 3": (param, 3), - "Relative Mode 4": (param, 4), - "Learn Relative Mode": (param, -1) - } - self.zyngui.screens['option'].config("Select CC mode", options, self.set_cc_mode) - self.zyngui.show_screen('option') + elif option == "CC Value Range": + self.zyngui.screens["midi_cc_range"].config(self.zgui_controllers[param].zctrl) + self.zyngui.show_screen('midi_cc_range') + else: + parts = option.split(" ") + if parts[1] == "X-axis": + zctrl = self.zgui_controllers[param].zctrl + if self.zyngui.state_manager.zctrl_x == zctrl: + self.zyngui.state_manager.zctrl_x = None + else: + self.zyngui.state_manager.zctrl_x = zctrl + if self.zyngui.state_manager.zctrl_y == zctrl: + self.zyngui.state_manager.zctrl_y = None + #self.refresh_midi_bind() + self.midi_learn_options(param, keep_selection=True) + elif parts[1] == "Y-axis": + zctrl = self.zgui_controllers[param].zctrl + if self.zyngui.state_manager.zctrl_y == zctrl: + self.zyngui.state_manager.zctrl_y = None + else: + self.zyngui.state_manager.zctrl_y = zctrl + if self.zyngui.state_manager.zctrl_x == zctrl: + self.zyngui.state_manager.zctrl_x = None + #self.refresh_midi_bind() + self.midi_learn_options(param, keep_selection=True) + elif parts[0] == "Chain": + self.midi_learn(param, MIDI_LEARNING_CHAIN) + elif parts[0] == "Global": + self.midi_learn(param, MIDI_LEARNING_GLOBAL) + elif parts[0] == "ZynStep": + try: + ccnum = int(parts[2][1:-1]) + except: + ccnum = None + self.zyngui.screens['midi_cc_single'].config(self.zynstep_midi_cc_cb, ccnum, param) + self.zyngui.show_screen('midi_cc_single') + elif parts[0] == "Unlearn": + if param: + self.midi_unlearn(param) + else: + self.midi_unlearn_action() + elif parts[1] == "Momentary": + if parts[0] == '\u2612': + self.zgui_controllers[param].zctrl.midi_cc_momentary_switch = 0 + else: + self.zgui_controllers[param].zctrl.midi_cc_momentary_switch = 1 + self.midi_learn_options(param, keep_selection=True) + elif parts[1] == "Debounce": + if parts[0] == '\u2612': + self.zgui_controllers[param].zctrl.midi_cc_debounce = 0 + else: + self.zgui_controllers[param].zctrl.midi_cc_debounce = 1 + self.midi_learn_options(param, keep_selection=True) + elif parts[0] in ["Relative", "Absolute"]: + options = { + "Absolute Mode": (param, 0), + "Absolute Reverse": (param, 0), + "Relative Mode 1": (param, 1), + "Relative Mode 2": (param, 2), + "Relative Mode 3": (param, 3), + "Relative Mode 4": (param, 4), + "Learn Relative Mode": (param, -1) + } + self.zyngui.screens['option'].config("Select CC mode", options, self.set_cc_mode) + self.zyngui.show_screen('option') def set_cc_mode(self, option, param): self.zgui_controllers[param[0]].zctrl.midi_cc_mode_set(param[1]) @@ -923,7 +962,7 @@ def set_cc_mode(self, option, param): def zynstep_midi_cc_cb(self, ccnum, i): zctrl = self.zgui_controllers[i].zctrl - self.zyngui.chain_manager.add_zynstep_midi_learn(ccnum, zctrl) + self.chain_manager.add_zynstep_midi_learn(ccnum, zctrl) self.midi_learn_options(i, keep_selection=True) def show_xy(self, params=None): @@ -952,9 +991,11 @@ def set_select_path(self): self.select_path.set(processor.get_basepath() + "/CHAIN Control MIDI-Learn") elif self.midi_learning == MIDI_LEARNING_GLOBAL: self.select_path.set(processor.get_basepath() + "/GLOBAL Control MIDI-Learn") + else: + self.select_path.set(processor.get_basepath() + "/CHAIN Control MIDI-Learn") else: self.select_path.set(processor.get_presetpath()) else: - self.select_path.set(self.zyngui.chain_manager.get_active_chain().get_title()) + self.select_path.set(self.chain_manager.get_active_chain().get_title()) # ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_gui_controller.py b/zyngui/zynthian_gui_controller.py index b80fb8b5e..281299c4e 100644 --- a/zyngui/zynthian_gui_controller.py +++ b/zyngui/zynthian_gui_controller.py @@ -27,7 +27,6 @@ import math import tkinter import logging -from datetime import datetime from tkinter import font as tkFont # Zynthian specific modules @@ -154,7 +153,7 @@ def __init__(self, index, parent, zctrl, hidden=False, selcounter=False, graph_t tags='gui') # Bind canvas events - self.canvas_push_ts = None + self.canvas_push_event = None self.bind("", self.cb_canvas_push) self.bind("", self.cb_canvas_release) self.bind("", self.cb_canvas_motion) @@ -503,11 +502,11 @@ def set_midi_bind(self, preselection=None): if self.zyngui.screens["control"].get_midi_learn() > 1: self.plot_midi_bind("??#??", zynthian_gui_config.color_ml) else: - self.plot_midi_bind("??", zynthian_gui_config.color_hl) + self.plot_midi_bind("??#??", zynthian_gui_config.color_hl) elif self.zctrl == self.zyngui.state_manager.zctrl_x: - self.plot_midi_bind("X") + self.plot_midi_bind("X", zynthian_gui_config.color_alt2) elif self.zctrl == self.zyngui.state_manager.zctrl_y: - self.plot_midi_bind("Y") + self.plot_midi_bind("Y", zynthian_gui_config.color_alt2) elif midi_learn_params := self.zyngui.chain_manager.get_midi_learn_from_zctrl(self.zctrl): key = midi_learn_params[0] cc = key & 0xff @@ -651,7 +650,7 @@ def config(self, zctrl): self.format_print = "{:.1f}" # Logarithmic float => It's calculated on-the-fly depending of the displayed value - #logging.debug(f"ZCTRL '{zctrl.short_name}' = {zctrl.value} ({zctrl.value_min} -> {zctrl.value_max}, {self.step}); {zctrl.labels}; {zctrl.ticks}") + #logging.debug(f"ZCTRL '{zctrl.short_name} ({zctrl.symbol})' = {zctrl.value} ({zctrl.value_min} -> {zctrl.value_max}, {self.step}); {zctrl.labels}; {zctrl.ticks}") self.setup_zynpot() # -------------------------------------------------------------------------- @@ -686,6 +685,7 @@ def zynpot_cb(self, dval): fine = True else: fine = self.zyngui.alt_mode + #logging.debug(f"ZCTRL_NUDGE({dval}, fine={fine} => ") return self.zctrl.nudge(dval, fine=fine) else: return False @@ -704,17 +704,17 @@ def nudge(self, dval, fine=False): # -------------------------------------------------------------------------- def cb_canvas_push(self, event): - self.canvas_push_ts = datetime.now() + self.canvas_push_event = event self.active_motion_axis = 0 # +1=dragging in y-axis, -1=dragging in x-axis self.canvas_motion_y0 = event.y self.canvas_motion_x0 = event.x self.canvas_motion_dx = 0 - #logging.debug(f"CONTROL {self.index} PUSH => {self.canvas_push_ts} ({self.canvas_motion_x0},{self.canvas_motion_y0})") + #logging.debug(f"CONTROL {self.index} PUSH => {self.canvas_push_event} ({self.canvas_motion_x0},{self.canvas_motion_y0})") def cb_canvas_release(self, event): - if self.canvas_push_ts and self.enabled: - dts = (datetime.now()-self.canvas_push_ts).total_seconds() - self.canvas_push_ts = None + if self.canvas_push_event and self.enabled and self.zctrl: + dts = (event.time - self.canvas_push_event.time) / 1000 + self.canvas_push_event = None #logging.debug(f"CONTROL {self.index} RELEASE => {dts}, {motion_rate}") if self.active_motion_axis == 0: if zynthian_gui_config.enable_touch_controller_switches: @@ -732,8 +732,8 @@ def cb_canvas_release(self, event): self.zyngui.cuia_v5_zynpot_switch((self.index, 'L')) # TODO: This should trigger before release def cb_canvas_motion(self, event): - if self.canvas_push_ts: - dts = (datetime.now() - self.canvas_push_ts).total_seconds() + if self.canvas_push_event: + dts = (event.time - self.canvas_push_event.time) / 1000 if dts > 0.1: # debounce initial touch dy = self.canvas_motion_y0 - event.y dx = event.x - self.canvas_motion_x0 diff --git a/zyngui/zynthian_gui_dpm.py b/zyngui/zynthian_gui_dpm.py index 539b4b1f6..aca8345a6 100644 --- a/zyngui/zynthian_gui_dpm.py +++ b/zyngui/zynthian_gui_dpm.py @@ -5,8 +5,8 @@ # # Zynthian GUI Digital Audio Peak Meters # -# Copyright (C) 2015-2023 Fernando Moyano -# Copyright (C) 2015-2023 Brian Walton +# Copyright (C) 2015-2025 Fernando Moyano +# Brian Walton # # ****************************************************************************** # @@ -30,12 +30,9 @@ class zynthian_gui_dpm(): - def __init__(self, zynmixer, strip, channel, parent, x0, y0, width, height, vertical=True, tags=()): + def __init__(self, parent, x0, y0, width, height, vertical=True, tags=()): """Initialise digital peak meter - zynmixer : zynmixer engine object - strip : Audio mixer strip - channel : Audio channel (0=A/Left, 1=B/Right) parent : Frame object within which to draw meter x0 : X coordinate of top left corner y0 : Y coordinate of top left corner @@ -43,29 +40,31 @@ def __init__(self, zynmixer, strip, channel, parent, x0, y0, width, height, vert height : height of widget vertical : True for vertical orientation else horizontal orientation tags : Optional list of tags for external control of GUI + fill: Optional background colour (default: None / transparent) """ - self.zynmixer = zynmixer - self.strip = strip # Audio mixer strip - self.channel = channel # Audio channel 0=A, 1=B self.parent = parent + self.vertical = vertical + self.tags = tags + + # initial geometry self.x0 = x0 self.y0 = y0 self.width = width self.height = height - self.vertical = vertical + self.x1 = x0 + width + self.y1 = y0 + height + # dB constants self.overdB = -3 self.highdB = -10 self.lowdB = -50 self.zerodB = -10 - self.mono = False - - self.hold_thickness = 1 + # Colors self.low_color = "#00AA00" self.low_hold_color = "#00FF00" - self.high_color = "#CCCC00" # yellow + self.high_color = "#CCCC00" self.high_hold_color = "#FFFF00" self.over_color = "#CC0000" self.over_hold_color = "#FF0000" @@ -74,58 +73,96 @@ def __init__(self, zynmixer, strip, channel, parent, x0, y0, width, height, vert self.line_color = "#999999" self.bg_color = color_panel_bg + self.hold_thickness = 1 + self.mono = False + + # --------------------------------------------- + # Compute bounds for initial position + # --------------------------------------------- + coords = self._compute_bounds() + + # --------------------------------------------- + # Create canvas items + # --------------------------------------------- + self.bg_over = parent.create_rectangle(*coords['bg_over'], width=0, fill=self.over_color, tags=tags) + self.bg_high = parent.create_rectangle(*coords['bg_high'], width=0, fill=self.high_color, tags=tags) + self.bg_low = parent.create_rectangle(*coords['bg_low'], width=0, fill=self.low_color, tags=tags) + self.overlay = parent.create_rectangle(*coords['overlay'], width=0, fill=self.bg_color, tags=tags) + self.hold = parent.create_rectangle(*coords['hold'], width=0, fill=self.low_color, tags=tags, state=HIDDEN) + self.zero_line = parent.create_line(*coords['line'], fill=self.line_color, tags=tags) + + # -------------------------------------------------- + # Helper to compute bounds + # -------------------------------------------------- + def _compute_bounds(self): + """ Compute all item coordinates and thresholds based on current geometry """ + x0, y0, x1, y1 = self.x0, self.y0, self.x1, self.y1 + width, height = self.width, self.height + if self.vertical: - self.x1 = x0 + width - self.y1 = y0 + height - self.y_over = int(self.y0 + self.height * self.overdB / self.lowdB) - self.y_high = int(self.y0 + self.height * self.highdB / self.lowdB) - self.y_low = y0 + height - y_zero = int(self.y0 + self.height * self.zerodB / self.lowdB) - - self.bg_over = self.parent.create_rectangle( - self.x0, self.y0, self.x1, self.y_over, width=0, fill=self.over_color, tags=tags) - self.bg_high = self.parent.create_rectangle( - self.x0, self.y_over, self.x1, self.y_high, width=0, fill=self.high_color, tags=tags) - self.bg_low = self.parent.create_rectangle( - self.x0, self.y_high, self.x1, self.y_low, width=0, fill=self.low_color, tags=tags) - self.overlay = self.parent.create_rectangle( - self.x0, self.y0, self.x1, self.y1, width=0, fill=self.bg_color, tags=tags) - self.hold = self.parent.create_rectangle( - self.x0, self.y_low, self.x1, self.y_low, width=0, fill=self.low_color, tags=tags, state=HIDDEN) - self.parent.create_line( - self.x0, y_zero, self.x1, y_zero, fill=self.line_color, tags=tags) + self.y_over = int(y0 + height * self.overdB / self.lowdB) + self.y_high = int(y0 + height * self.highdB / self.lowdB) + self.y_low = y1 + self.y_zero = int(y0 + height * self.zerodB / self.lowdB) + + coords = { + 'bg_over': (x0, y0, x1, self.y_over), + 'bg_high': (x0, self.y_over, x1, self.y_high), + 'bg_low': (x0, self.y_high, x1, self.y_low), + 'overlay': (x0, y0, x1, y1), + 'hold': (x0, self.y_low, x1, self.y_low), + 'line': (x0, self.y_zero, x1, self.y_zero) + } else: - self.x1 = x0 + width - self.y1 = y0 + height - self.x_over = int(self.x1 - self.width * self.overdB / self.lowdB) - self.x_high = int(self.x1 - self.width * self.highdB / self.lowdB) + self.x_over = int(x1 - width * self.overdB / self.lowdB) + self.x_high = int(x1 - width * self.highdB / self.lowdB) self.x_low = x0 - x_zero = int(self.x1 - self.width * self.zerodB / self.lowdB) - - self.bg_over = self.parent.create_rectangle( - self.x_over, self.y0, self.x1, self.y1, width=0, fill=self.over_color, tags=tags) - self.bg_high = self.parent.create_rectangle( - self.x_high, self.y0, self.x_over, self.y1, width=0, fill=self.high_color, tags=tags) - self.bg_low = self.parent.create_rectangle( - self.x_low, self.y0, self.x_high, self.y1, width=0, fill=self.low_color, tags=tags) - self.overlay = self.parent.create_rectangle( - self.x0, self.y0, self.x1, self.y1, width=0, fill=self.bg_color, tags=tags) - self.hold = self.parent.create_rectangle( - self.x_low, self.y0, self.x_low, self.y1, width=0, fill=self.low_color, tags=tags, state=HIDDEN) - self.parent.create_line( - x_zero, self.y0, x_zero, self.y1, fill=self.line_color, tags=tags) - - def set_strip(self, strip): - """Set the mixer channel strip - - strip : Mixer channel strip + self.x_zero = int(x1 - width * self.zerodB / self.lowdB) + + coords = { + 'bg_over': (self.x_over, y0, x1, y1), + 'bg_high': (self.x_high, y0, self.x_over, y1), + 'bg_low': (self.x_low, y0, self.x_high, y1), + 'overlay': (x0, y0, x1, y1), + 'hold': (self.x_low, y0, self.x_low, y1), + 'line': (self.x_zero, y0, self.x_zero, y1) + } + + return coords + + # -------------------------------------------------- + # Move method reuses same computation + # -------------------------------------------------- + def move(self, x0, y0, width, height): + """ Move the meter to another part of the screen + Args: + x0: New x coordinate + y0: New y coordinate + width: New width + height: New height """ - self.strip = strip + + self.x0 = x0 + self.y0 = y0 + self.width = width + self.height = height + self.x1 = x0 + width + self.y1 = y0 + height + + coords = self._compute_bounds() + + self.parent.coords(self.bg_over, *coords['bg_over']) + self.parent.coords(self.bg_high, *coords['bg_high']) + self.parent.coords(self.bg_low, *coords['bg_low']) + self.parent.coords(self.overlay, *coords['overlay']) + self.parent.coords(self.hold, *coords['hold']) + self.parent.coords(self.zero_line, *coords['line']) + + def set_enable(self, enable): + self.enabled = enable def refresh(self, dpm, hold, mono): - if self.strip is None: - return if mono != self.mono: self.mono = mono if mono: @@ -169,7 +206,7 @@ def refresh(self, dpm, hold, mono): self.parent.itemconfig( self.hold, state=NORMAL, fill=self.high_hold_color) elif x0 > self.x_low: - if self.zynmixer.get_mono(self.strip): + if self.mono: self.parent.itemconfig( self.hold, state=NORMAL, fill=self.mono_hold_color) else: diff --git a/zyngui/zynthian_gui_engine.py b/zyngui/zynthian_gui_engine.py index 6fd2ce767..c17bebe06 100644 --- a/zyngui/zynthian_gui_engine.py +++ b/zyngui/zynthian_gui_engine.py @@ -75,7 +75,6 @@ def __init__(self): super().__init__('Engine', True, False) self.chain_manager = self.zyngui.chain_manager - self.engine_info = self.chain_manager.engine_info self.engine_info_dirty = False self.xswipe_sens = 10 @@ -165,7 +164,7 @@ def get_info(self, eng_code=None): if not eng_code: eng_code = self.list_data[self.index][0] try: - return self.engine_info[eng_code] + return self.chain_manager.engine_info[eng_code] except: logging.info(f"Can't get info for engine '{eng_code}'") return {"QUALITY": 0, "COMPLEX": 0, "DESCR": ""} @@ -197,9 +196,13 @@ def show_details(self, eng_code=None): def get_engines_by_cat(self): self.chain_manager.get_engine_info() - self.engine_info = self.chain_manager.engine_info self.proc_type = self.zyngui.modify_chain_status["type"] self.engines_by_cat = self.chain_manager.filtered_engines_by_cat(self.proc_type, all=self.show_all) + for exclude in ["MI", "MR", "MX"]: + try: + self.engines_by_cat["Other"].pop(exclude) + except: + pass self.engine_cats = list(self.engines_by_cat.keys()) logging.debug(f"CATEGORIES => {self.engine_cats}") # self.engines_by_cat = sorted(self.engines_by_cat.items(), key=lambda kv: "!" if kv[0] is None else kv[0]) @@ -303,43 +306,19 @@ def select_action(self, i, t='S'): if i is not None and self.list_data[i][0]: engine = self.list_data[i][0] if self.show_all: - self.engine_info[engine]['ENABLED'] = not self.engine_info[engine]['ENABLED'] - if self.engine_info[engine]['EDIT'] == 0: - self.engine_info[engine]['EDIT'] = 1 + info = self.chain_manager.engine_info[engine] + info['ENABLED'] = not info['ENABLED'] + if info['EDIT'] == 0: + info['EDIT'] = 1 self.engine_info_dirty = True self.update_list() else: self.zyngui.modify_chain_status["engine"] = engine if "chain_id" in self.zyngui.modify_chain_status: # Modifying existing chain - if "processor" in self.zyngui.modify_chain_status: - # Replacing processor - pass - else: - slot_count = self.chain_manager.get_slot_count( - self.zyngui.modify_chain_status["chain_id"], self.zyngui.modify_chain_status["type"]) - if self.zyngui.modify_chain_status["type"] == "Audio Effect": - # Check for fader position - post_fader = "post_fader" in self.zyngui.modify_chain_status and self.zyngui.modify_chain_status["post_fader"] - fader_pos = self.chain_manager.get_chain(self.zyngui.modify_chain_status["chain_id"]).fader_pos - if post_fader and slot_count > fader_pos or not post_fader and slot_count > 0: - ask_parallel = True - else: - ask_parallel = False - else: - ask_parallel = slot_count > 0 - if ask_parallel: - # Adding to slot with existing processor - choose parallel/series - self.zyngui.screens['option'].config("Chain Mode", - {"Series": False, "Parallel": True}, - self.cb_add_parallel) - self.zyngui.show_screen('option') - return - else: - self.zyngui.modify_chain_status["parallel"] = False + pass else: # Adding engine to new chain - self.zyngui.modify_chain_status["parallel"] = False if engine == "AP": # TODO: Better done with engine flag self.zyngui.modify_chain_status["audio_thru"] = False @@ -376,9 +355,13 @@ def switch(self, swi, t='S'): self.show_details() return True - def cb_add_parallel(self, option, value): - self.zyngui.modify_chain_status['parallel'] = value - self.zyngui.modify_chain() + def cuia_v5_zynpot_switch(self, params): + i = params[0] + t = params[1].upper() + if i == 2 and t == 'S': + self.show_details() + return True + return False def set_selector(self, zs_hidden=False): super().set_selector(zs_hidden) diff --git a/zyngui/zynthian_gui_file_selector.py b/zyngui/zynthian_gui_file_selector.py index 7cd3258ac..78f7994aa 100644 --- a/zyngui/zynthian_gui_file_selector.py +++ b/zyngui/zynthian_gui_file_selector.py @@ -137,7 +137,7 @@ def config(self, cb_func, fexts=None, dirnames=None, path=None, preload=False): def hide(self): # Restore initial selection if it was changed while preloading - if self.shown and self.cb_func and self.sel_path != self.init_path and os.path.isfile(self.init_path): + if self.shown and self.cb_func and self.init_path and self.sel_path != self.init_path and os.path.isfile(self.init_path): self.cb_func(self.init_path) super().hide() diff --git a/zyngui/zynthian_gui_keyboard.py b/zyngui/zynthian_gui_keyboard.py index a0e267b87..4d41fe5d0 100644 --- a/zyngui/zynthian_gui_keyboard.py +++ b/zyngui/zynthian_gui_keyboard.py @@ -315,8 +315,8 @@ def show(self, function, text="", max_len=None): # Function to register encoders def setup_zynpots(self): if zynthian_gui_config.num_zynpots > 3: - lib_zyncore.setup_behaviour_zynpot(3, 1) - lib_zyncore.setup_behaviour_zynpot(1, 1) + lib_zyncore.setup_behaviour_zynpot(self.ctrl_order[2], 1) + lib_zyncore.setup_behaviour_zynpot(self.ctrl_order[3], 1) # Function to handle zynpots events def zynpot_cb(self, i, dval): diff --git a/zyngui/zynthian_gui_main_menu.py b/zyngui/zynthian_gui_main_menu.py index c2512a804..d7492a1fe 100644 --- a/zyngui/zynthian_gui_main_menu.py +++ b/zyngui/zynthian_gui_main_menu.py @@ -42,38 +42,39 @@ def fill_list(self): self.list_data = [] # Chain & Sequence Management - try: - self.zyngui.chain_manager.get_next_free_mixer_chan() - mixer_avail = True - except: - mixer_avail = False self.list_data.append((None, 0, "> ADD CHAIN")) - if mixer_avail: - self.list_data.append((self.add_synth_chain, 0, - "Add Instrument Chain", - ["Create a new chain with a MIDI-controlled synth engine. The chain receives MIDI input and generates audio output.", - "midi_instrument.png"])) - self.list_data.append((self.add_audiofx_chain, 0, - "Add Audio Chain", - ["Create a new chain for audio FX processing. The chain receives audio input and generates audio output.", - "microphone.png"])) + self.list_data.append((self.add_synth_chain, 0, + "Add Instrument Chain", + ["Create a new chain with a MIDI-controlled synth engine. The chain receives MIDI input and generates audio output.", + "midi_instrument.png"])) + self.list_data.append((self.add_audio_chain, 0, + "Add Audio Input Chain", + ["Create a new chain for audio FX processing. The chain receives audio input and generates audio output.", + "microphone.png"])) + self.list_data.append((self.add_clippy_chain, 0, + "Add Clip Chain", + ["Create a new chain with audio clip launcher. The chain receives trigger/stop events from the sequencer and generates audio output.", + "audio.png"])) + self.list_data.append((self.add_mixbus_chain, 0, + "Add Mixbus Chain", + ["Create a mixbus chain for processing audio (FX send).", + "effects_loop.png"])) self.list_data.append((self.add_midifx_chain, 0, "Add MIDI Chain", ["Create a new chain for MIDI processing. The chain receives MIDI input and generates MIDI output.", "midi_logo.png"])) - if mixer_avail: - self.list_data.append((self.add_midiaudiofx_chain, 0, - "Add MIDI+Audio Chain", - ["Create a new chain for combined audio + MIDI processing. The chain receives audio & MIDI input and generates audio & MIDI output. Use it with vocoders, autotune, etc.", - "midi_audio.png"])) - self.list_data.append((self.add_generator_chain, 0, - "Add Audio Generator Chain", - ["Create a new chain for audio generation. The chain doesn't receive any input and generates audio output. Internet radio, test signals, etc.", - "audio_generator.png"])) - self.list_data.append((self.add_special_chain, 0, - "Add Special Chain", - ["Create a new chain for special processing. The chain receives audio & MIDI input and generates audio & MIDI output. use it for MOD-UI, puredata, etc.", - "special_chain.png"])) + self.list_data.append((self.add_midiaudiofx_chain, 0, + "Add MIDI+Audio Chain", + ["Create a new chain for combined audio + MIDI processing. The chain receives audio & MIDI input and generates audio & MIDI output. Vocoders, autotune, etc.", + "midi_audio.png"])) + self.list_data.append((self.add_generator_chain, 0, + "Add Audio Generator Chain", + ["Create a new chain for audio generation. The chain doesn't receive any input and generates audio output. Internet radio, test signals, etc.", + "audio_generator.png"])) + self.list_data.append((self.add_special_chain, 0, + "Add Special Chain", + ["Create a new chain for special processing. The chain receives audio & MIDI input and generates audio & MIDI output. MOD-UI, puredata, etc.", + "special_chain.png"])) self.list_data.append((None, 0, "> REMOVE")) self.list_data.append((self.remove_sequences, 0, @@ -92,12 +93,11 @@ def fill_list(self): # Add list of Apps self.list_data.append((None, 0, "> MAIN")) self.list_data.append((self.snapshots, 0, "Snapshots", ["Show snapshots management menu.", "snapshot.png"])) - self.list_data.append((self.step_sequencer, 0, "Sequencer", ["Show sequencer's zynpad view.", "sequencer.png"])) + #self.list_data.append((self.step_sequencer, 0, "Sequencer", ["Show sequencer.", "sequencer.png"])) self.list_data.append((self.audio_recorder, 0, "Audio Recorder", ["Show audio recorder/player.", "audio_recorder.png"])) self.list_data.append((self.midi_recorder, 0, "MIDI Recorder", ["Show SMF recorder/player.", "midi_recorder.png"])) self.list_data.append((self.tempo_settings, 0, "Tempo Settings", ["Show tempo & sync options.", "metronome.png"])) self.list_data.append((self.audio_levels, 0, "Audio Levels", ["Show audio levels view.", "meters.png"])) - self.list_data.append((self.audio_mixer_learn, 0, "Mixer Learn", ["Enter mixer's MIDI learn mode", "mixer.png"])) # Add list of System / configuration views self.list_data.append((None, 0, "> SYSTEM")) @@ -116,10 +116,18 @@ def add_synth_chain(self, t='S'): self.zyngui.modify_chain( {"type": "MIDI Synth", "midi_thru": False, "audio_thru": False}) - def add_audiofx_chain(self, t='S'): + def add_audio_chain(self, t='S'): self.zyngui.modify_chain( {"type": "Audio Effect", "midi_thru": False, "audio_thru": True}) + def add_clippy_chain(self, t='S'): + self.zyngui.modify_chain( + {"type": "Audio Generator", "midi_thru": False, "audio_thru": False, "engine": "CL", "midi_chan": None}) + + def add_mixbus_chain(self, t='S'): + self.zyngui.modify_chain( + {"type": "Audio Effect", "midi_thru": False, "audio_thru": True, "mixbus": True}) + def add_midifx_chain(self, t='S'): self.zyngui.modify_chain( {"type": "MIDI Tool", "midi_thru": True, "audio_thru": False}) @@ -166,7 +174,7 @@ def remove_sequences_confirmed(self, params=None): def step_sequencer(self, t='S'): logging.info("Step Sequencer") - self.zyngui.show_screen('zynpad') + self.zyngui.show_screen('arranger') def audio_recorder(self, t='S'): logging.info("Audio Recorder/Player") @@ -176,10 +184,6 @@ def midi_recorder(self, t='S'): logging.info("MIDI Recorder/Player") self.zyngui.show_screen("midi_recorder") - def audio_mixer_learn(self, t='S'): - logging.info("Audio Mixer Learn") - self.zyngui.screens["audio_mixer"].midi_learn_menu() - def audio_levels(self, t='S'): logging.info("Audio Levels") self.zyngui.show_screen("alsa_mixer") diff --git a/zyngui/zynthian_gui_midi_cc.py b/zyngui/zynthian_gui_midi_cc.py index 7769b6bae..7c18d7be3 100644 --- a/zyngui/zynthian_gui_midi_cc.py +++ b/zyngui/zynthian_gui_midi_cc.py @@ -58,17 +58,14 @@ def build_layout(self): def fill_list(self): self.list_data = [] - self.cc_route = (ctypes.c_uint8 * 128)() lib_zyncore.zmop_get_cc_route(self.zmop_index, self.cc_route) - for ccnum, enabled in enumerate(self.cc_route): if enabled: - self.list_data.append( - (str(ccnum), ccnum, "\u2612 CC {}".format(str(ccnum).zfill(2)))) + bullet = "\u2612" else: - self.list_data.append( - (str(ccnum), ccnum, "\u2610 CC {}".format(str(ccnum).zfill(2)))) + bullet = "\u2610" + self.list_data.append((str(ccnum), ccnum, f"{bullet} CC {str(ccnum).zfill(2)}")) super().fill_list() def select_action(self, i, t='S'): diff --git a/zyngui/zynthian_gui_midi_cc_range.py b/zyngui/zynthian_gui_midi_cc_range.py new file mode 100644 index 000000000..4203b4815 --- /dev/null +++ b/zyngui/zynthian_gui_midi_cc_range.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian GUI +# +# Zynthian GUI MIDI CC range config class +# +# Copyright (C) 2015-2026 Fernando Moyano +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import tkinter +import logging + +# Zynthian specific modules +from zyngine import zynthian_controller +from zyngine.zynthian_signal_manager import zynsigman +from zyngui import zynthian_gui_config +from zyngui.zynthian_gui_base import zynthian_gui_base +from zyngui.zynthian_gui_controller import zynthian_gui_controller + +# ------------------------------------------------------------------------------ +# Zynthian MIDI key-range GUI Class +# ------------------------------------------------------------------------------ + + +class zynthian_gui_midi_cc_range(zynthian_gui_base): + + def __init__(self): + super().__init__() + + self.zctrl = None + + self.text_color = zynthian_gui_config.color_tx + self.plot_color = zynthian_gui_config.color_on + self.axis_color = zynthian_gui_config.color_hl + #self.font_axis = ("sans", zynthian_gui_config.font_size) + self.font_axis = (zynthian_gui_config.font_family, int(1.0 * zynthian_gui_config.font_size)) + + self.main_frame.rowconfigure(0, weight=1) + self.main_frame.rowconfigure(1, weight=1) + + # Plot canvas + self.plot_width = int(3 * self.width / 4) + self.plot_height = self.height + self.mgx = self.plot_width // 10 + self.mgy = self.plot_height // 10 + self.plot_canvas = tkinter.Canvas(self.main_frame, + width=self.plot_width, + height=self.plot_height, + bd=0, + highlightthickness=0, + bg="#000000") + if zynthian_gui_config.layout['columns'] == 3: + self.plot_canvas.grid(row=0, column=0, rowspan=2, columnspan=2) + else: + self.plot_canvas.grid(row=0, column=0, rowspan=4, columnspan=1) + self.plot_canvas.bind("", self.cb_plot_press) + self.plot_canvas.bind("", self.on_plot_motion) + + # Create and plot controllers + self.zgui_ctrls = [None, None, None, None] + # Slots 1 & 2 are empty + for j in range(0, 2): + i = zynthian_gui_config.layout['ctrl_order'][j] + self.zgui_ctrls[i] = zynthian_gui_controller(i, self.main_frame, None) + # Slot 3 => Val1 + i = zynthian_gui_config.layout['ctrl_order'][2] + self.v1_zctrl = zynthian_controller(self, 'Value at Min') + self.v1_zgui_ctrl = zynthian_gui_controller(i, self.main_frame, self.v1_zctrl) + self.zgui_ctrls[i] = self.v1_zgui_ctrl + # Slot 4 => Val2 + i = zynthian_gui_config.layout['ctrl_order'][3] + self.v2_zctrl = zynthian_controller(self, 'Value at Max') + self.v2_zgui_ctrl = zynthian_gui_controller(i, self.main_frame, self.v2_zctrl) + self.zgui_ctrls[i] = self.v2_zgui_ctrl + # Display widgets + if zynthian_gui_config.layout['columns'] == 3: + self.v1_zgui_ctrl.configure(height=self.height // 2, width=self.width // 4) + self.v2_zgui_ctrl.configure(height=self.height // 2, width=self.width // 4) + self.v1_zgui_ctrl.grid(row=0, column=2, pady=(0, 1)) + self.v2_zgui_ctrl.grid(row=1, column=2, pady=(1, 0)) + else: + for i in range(0, 4): + self.zgui_ctrls[i].configure(height=self.height // 4, width=self.width // 4) + self.zgui_ctrls[i].grid(row=i, column=2, pady=(1, 1)) + + self.plot() + self.replot = True + + def build_view(self): + self.replot = True + if not self.shown: + zynsigman.register_queued(zynsigman.S_STATE_MAN, self.state_manager.SS_LOAD_ZS3, self.cb_load_zs3) + return True + + def hide(self): + if self.shown: + zynsigman.unregister(zynsigman.S_STATE_MAN, self.state_manager.SS_LOAD_ZS3, self.cb_load_zs3) + super().hide() + + def config(self, zctrl): + self.zctrl = zctrl + self.setup_zctrls() + self.replot = True + self.set_select_path() + + def setup_zctrls(self): + self.v1_zctrl.set_options({ + 'value_min': self.zctrl.value_min, + 'value_max': self.zctrl.value_max, + 'value': self.zctrl.midi_cc_val1}) + self.v1_zgui_ctrl.config(self.v1_zctrl) + self.v1_zgui_ctrl.setup_zynpot() + + self.v2_zctrl.set_options({ + 'value_min': self.zctrl.value_min, + 'value_max': self.zctrl.value_max, + 'value': self.zctrl.midi_cc_val2}) + self.v2_zgui_ctrl.config(self.v2_zctrl) + self.v2_zgui_ctrl.setup_zynpot() + + def cb_load_zs3(self, zs3_id): + """Handle LOAD_ZS3 signal + + zs3_id : ID of loaded zs3 + """ + + self.v1_zgui_ctrl.is_dirty = True + self.v2_zgui_ctrl.is_dirty = True + self.replot = True + + def plot_zctrls(self): + if self.replot: + for zgui_ctrl in self.zgui_ctrls: + if zgui_ctrl and zgui_ctrl.zctrl and zgui_ctrl.zctrl.is_dirty: + zgui_ctrl.calculate_plot_values() + zgui_ctrl.plot_value() + zgui_ctrl.zctrl.is_dirty = False + self.update_plot() + self.replot = False + + def switch_select(self, t='S'): + self.zyngui.close_screen() + + def zynpot_cb(self, i, dval): + try: + self.zgui_ctrls[i].zynpot_cb(dval) + return True + except: + return False + + def zynpot_abs(self, i, val): + try: + self.zgui_ctrls[i].zynpot_abs(val) + return True + except: + return False + + def send_controller_value(self, zctrl): + if self.shown and self.zctrl is not None: + if zctrl == self.v1_zctrl: + self.zctrl.midi_cc_val1 = zctrl.value + self.zctrl._configure() + #logging.debug("SETTING MIDI CC VAL1: {}".format(zctrl.value)) + self.replot = True + elif zctrl == self.v2_zctrl: + self.zctrl.midi_cc_val2 = zctrl.value + self.zctrl._configure() + #logging.debug("SETTING MIDI CC VAL2: {}".format(zctrl.value)) + self.replot = True + + def set_select_path(self): + try: + self.select_path.set(f"CC Value Range: {self.zctrl.name}") + except: + self.select_path.set("CC Value Range") + + def plot(self): + y0 = self.plot_height - self.mgy + x0 = self.mgx - zynthian_gui_config.font_size + x1 = self.plot_width - self.mgx + + # Vertical lines + self.plot_canvas.create_line(self.mgx, y0, self.mgx, self.mgy, + fill=self.axis_color, tags="axis") + self.plot_canvas.create_line(x1, y0, x1, self.mgy, + fill=self.axis_color, tags="axis") + + # Horizontal lines & Y-labels + n_ticks = 4 + for i in range(0, n_ticks + 1): + y = y0 + int(i * (self.mgy - y0) / n_ticks) + self.plot_canvas.create_line(x0, y, x1, y, + fill=self.axis_color, tags="axis") + self.plot_canvas.create_text(x0, y, anchor=tkinter.E, text=str(int(i * 128 / n_ticks)), + fill=self.text_color, font=self.font_axis, tags="axis") + + def update_plot(self): + # Delete "replot" elements + self.plot_canvas.delete("replot") + + # Do some maths + if self.zctrl.value_range == 0: + return + k = (self.plot_width - 2 * self.mgx) / self.zctrl.value_range + x1 = self.mgx + int(self.zctrl.midi_cc_val1 * k) + x2 = self.mgx + int(self.zctrl.midi_cc_val2 * k) + y0 = self.plot_height - self.mgy + dash = self.mgx // 4 + + # Plot value 1 dashed line + self.plot_canvas.create_line(x1, y0, x1, self.mgy, + fill=self.axis_color, dash=(dash, dash), tags="replot") + # Plot value 2 dashed line + self.plot_canvas.create_line(x2, y0, x2, self.mgy, + fill=self.axis_color, dash=(dash, dash), tags="replot") + # Plot linear value range representation + self.plot_canvas.create_line(x1, y0, x2, self.mgy, + fill=self.plot_color, width=3, tags="replot") + + # X Axis labels + y0 += zynthian_gui_config.font_size + if self.zctrl.midi_cc_val1 < self.zctrl.midi_cc_val2: + a1 = tkinter.NE + a2 = tkinter.NW + else: + a1 = tkinter.NW + a2 = tkinter.NE + self.plot_canvas.create_text(x1, y0, anchor=a1, text=self.v1_zgui_ctrl.format_print.format(self.zctrl.midi_cc_val1), + fill=self.text_color, font=self.font_axis, tags="replot") + self.plot_canvas.create_text(x2, y0, anchor=a2, text=self.v2_zgui_ctrl.format_print.format(self.zctrl.midi_cc_val2), + fill=self.text_color, font=self.font_axis, tags="replot") + + + def cb_plot_press(self, event): + pass + + def on_plot_motion(self, event): + pass + +# ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_gui_midi_cc_single.py b/zyngui/zynthian_gui_midi_cc_single.py new file mode 100644 index 000000000..ee599be87 --- /dev/null +++ b/zyngui/zynthian_gui_midi_cc_single.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian GUI +# +# Zynthian GUI Midi-CC Single Selector Class +# +# Copyright (C) 2015-2025 Fernando Moyano +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import logging + +# Zynthian specific modules +from zyncoder.zyncore import lib_zyncore +from zyngui.zynthian_gui_selector import zynthian_gui_selector + +# ------------------------------------------------------------------------------ +# Zynthian single CC number selection GUI Class +# ------------------------------------------------------------------------------ + + +class zynthian_gui_midi_cc_single(zynthian_gui_selector): + + def __init__(self): + self.cb_func = None + self.cc_num = None + self.param = None + super().__init__('CC', True) + + def config(self, cb_func, cc_num, param=None): + self.cb_func = cb_func + self.cc_num = cc_num + self.param = param + + def fill_list(self): + self.list_data = [] + for ccnum in range(1, 128): + self.list_data.append((str(ccnum), ccnum, f"{str(ccnum).zfill(2)}")) + if isinstance(self.cc_num, int) and 0 < self.cc_num < 128: + self.index = self.cc_num - 1 + super().fill_list() + + def select_action(self, i, t='S'): + self.cc_num = self.list_data[i][1] + self.zyngui.close_screen() + self.cb_func(self.cc_num, self.param) + + def set_select_path(self): + self.select_path.set("CC") + +# ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_gui_midi_chan.py b/zyngui/zynthian_gui_midi_chan.py index 77254ba7f..947f1dc86 100644 --- a/zyngui/zynthian_gui_midi_chan.py +++ b/zyngui/zynthian_gui_midi_chan.py @@ -110,13 +110,13 @@ def select_action(self, i, t='S'): self.zyngui.modify_chain() elif self.mode == 'SET': self.zyngui.chain_manager.set_midi_chan( - self.zyngui.chain_manager.active_chain_id, selchan) + self.zyngui.chain_manager.active_chain.chain_id, selchan) zynautoconnect.request_midi_connect(True) - self.zyngui.screens['audio_mixer'].refresh_visible_strips() self.zyngui.close_screen() def midi_chan_activity(self, chan): - if self.shown and not self.zyngui.state_manager.zynseq.libseq.transportGetPlayStatus(): + #TODO: The guard against acitivating when playing only supports jack transport, not internal transport, like zynseq + if self.shown and not self.zyngui.state_manager.zynseq.libseq.getTransportState(): i = self.get_midi_chan_index(chan) if i is not None and i != self.index: dts = (datetime.now()-self.last_index_change_ts).total_seconds() diff --git a/zyngui/zynthian_gui_midi_config.py b/zyngui/zynthian_gui_midi_config.py index 2fcffb286..a4e3694bd 100644 --- a/zyngui/zynthian_gui_midi_config.py +++ b/zyngui/zynthian_gui_midi_config.py @@ -32,11 +32,14 @@ from subprocess import check_output, Popen, PIPE # Zynthian specific modules +import zynconf import zynautoconnect from zyncoder.zyncore import lib_zyncore -from zyngui.zynthian_gui_selector_info import zynthian_gui_selector_info +from zyngine.ctrldev.zynthian_ctrldev_base import SCROLL_MODE_DISABLED, SCROLL_MODE_FIXED, SCROLL_MODE_GUI_SEL, SCROLL_MODE_GUI_VIEW, SCROLL_MODE_CTRLDEV from zyngui import zynthian_gui_config -import zynconf +from zyngui.zynthian_gui_selector_info import zynthian_gui_selector_info + + # ------------------------------------------------------------------------------ # Mini class to allow use of audio_in gui @@ -60,12 +63,14 @@ def toggle_audio_in(self, input): # Zynthian MIDI config GUI Class # ------------------------------------------------------------------------------ -ZMIP_MODE_SYS = "♣" # \u1 -ZMIP_MODE_SYS_RT = "⏱" # \u23F1 -#ZMIP_MODE_SYS_RT = "⌛" # \u231B -ZMIP_MODE_CONTROLLER = "⌨" # \u2328 -ZMIP_MODE_ACTIVE = "⇥" # \u21e5 -ZMIP_MODE_MULTI = "⇶" # \u21f6 +ZMIP_ICON_MODE_ACTIVE = "⇥" # \u21e5 +ZMIP_ICON_MODE_MULTI = "⇶" # \u21f6 +ZMIP_ICON_SEQ_EXCL = "♣" +ZMIP_ICON_MIDI_CLOCK = "⏱" # \u23F1 +#ZMIP_ICON_MIDI_SYS = "♣" # \u1 +#ZMIP_ICON_MIDI_SYS_RT = "⌛" # \u231B +ZMIP_ICON_CTRLDEV_DRIVER = "⌨" # \u2328 + SERVICE_ICONS = { "aubionotes": "midi_audio.png" } @@ -117,16 +122,21 @@ def get_mode_str(idev): if idev is None: return mode_str if self.input: + port = zynautoconnect.devices_in[idev] if zynautoconnect.get_midi_in_dev_mode(idev): - mode_str += ZMIP_MODE_ACTIVE + mode_str += ZMIP_ICON_MODE_ACTIVE else: - mode_str += ZMIP_MODE_MULTI - if lib_zyncore.zmip_get_flag_system(idev): - mode_str += f" {ZMIP_MODE_SYS}" - if lib_zyncore.zmip_get_flag_system_rt(idev): - mode_str += f" {ZMIP_MODE_SYS_RT}" + mode_str += ZMIP_ICON_MODE_MULTI + if port.aliases[0] not in zynautoconnect.get_zynseq_exclude_ports(): + mode_str += " " + ZMIP_ICON_SEQ_EXCL + if zynautoconnect.get_ext_clock_zmip() == idev: + mode_str += " " + ZMIP_ICON_MIDI_CLOCK + #if lib_zyncore.zmip_get_flag_system(idev): + # mode_str += " " + ZMIP_ICON_MIDI_SYS + #if lib_zyncore.zmip_get_flag_system_rt(idev): + # mode_str += " " + ZMIP_ICON_MIDI_SYS_RT if idev in self.zyngui.state_manager.ctrldev_manager.drivers: - mode_str += f" {ZMIP_MODE_CONTROLLER}" + mode_str += " " + ZMIP_ICON_CTRLDEV_DRIVER if mode_str: mode_str += " " return mode_str @@ -136,11 +146,13 @@ def append_port(idev): if self.input: port = zynautoconnect.devices_in[idev] mode = get_mode_str(idev) - input_mode_info = f"\n\n{ZMIP_MODE_ACTIVE} Active mode\n" - input_mode_info += f"{ZMIP_MODE_MULTI} Multitimbral mode\n" - input_mode_info += f"{ZMIP_MODE_SYS} System messages\n" - input_mode_info += f"{ZMIP_MODE_SYS_RT} Transport messages\n" - input_mode_info += f"{ZMIP_MODE_CONTROLLER} Driver loaded" + input_mode_info = f"\n\n{ZMIP_ICON_MODE_ACTIVE} Active mode\n" + input_mode_info += f"{ZMIP_ICON_MODE_MULTI} Multitimbral mode\n" + input_mode_info += f"{ZMIP_ICON_SEQ_EXCL} Sequencer capture excluded\n" + input_mode_info += f"{ZMIP_ICON_MIDI_CLOCK} MIDI Clock\n" + #input_mode_info += f"{ZMIP_ICON_MIDI_SYS} System non-RT\n" + #input_mode_info += f"{ZMIP_ICON_MIDI_SYS_RT} System RT\n" + input_mode_info += f"{ZMIP_ICON_CTRLDEV_DRIVER} Driver loaded" if self.chain is None: self.list_data.append((port.aliases[0], idev, f"{mode}{port.aliases[1]}", [f"Bold select to show options for '{port.aliases[1]}'.{input_mode_info}", "midi_input.png"])) @@ -156,14 +168,18 @@ def append_port(idev): [f"'{port.aliases[1]}' disconnected from chain's MIDI input.\nBold select to show more options.{input_mode_info}", "midi_input.png"])) else: port = zynautoconnect.devices_out[idev] + if port.aliases[0] in zynautoconnect.get_midi_clock_output_ports(): + name = f"{ZMIP_ICON_MIDI_CLOCK} {port.aliases[1]}" + else: + name = port.aliases[1] if self.chain is None: - self.list_data.append((port.aliases[0], idev, f"{port.aliases[1]}", + self.list_data.append((port.aliases[0], idev, name, [f"Bold select to show options for '{port.aliases[1]}'.", "midi_output.png"])) elif port.aliases[0] in self.chain.midi_out: - self.list_data.append((port.aliases[0], idev, f"\u2612 {port.aliases[1]}", + self.list_data.append((port.aliases[0], idev, f"\u2612 {name}", [f"Chain's MIDI output connected to '{port.aliases[1]}'.\nBold select to show more options.", "midi_output.png"])) else: - self.list_data.append((port.aliases[0], idev, f"\u2610 {port.aliases[1]}", + self.list_data.append((port.aliases[0], idev, f"\u2610 {name}", [f"Chain's MIDI output disconnected from '{port.aliases[1]}'.\nBold select to show more options.", "midi_output.png"])) def append_service(service, name, help_info=""): @@ -213,6 +229,15 @@ def natural_keys(t): else: int_devices.append(i) + if self.chain and self.input: + midi_chan = self.chain.midi_chan + 1 + if midi_chan > 16: + midi_chan = "ALL" + self.list_data.append(("MIDI Channel", None, f"MIDI Channel ({midi_chan})", + [f"Select the MIDI channel this chain recieves.", "midi_settings.png"])) + self.list_data.append(("MIDI CC", None, f"MIDI CC", + [f"Select MIDI CC numbers passed-thru to chain processors. It could interfere with MIDI-learning. Use with caution!", "midi_settings.png"])) + self.list_data.append((None, None, "Internal Devices")) nint = len(self.list_data) @@ -268,10 +293,10 @@ def natural_keys(t): if not self.input and self.chain: self.list_data.append((None, None, "> Chain inputs")) - for i, chain_id in enumerate(self.zyngui.chain_manager.ordered_chain_ids): + for i, chain_id in enumerate(self.zyngui.chain_manager.chains): chain = self.zyngui.chain_manager.get_chain(chain_id) - if chain and chain.is_midi() and chain != self.chain: - if self.zyngui.chain_manager.will_midi_howl(self.zyngui.chain_manager.active_chain_id, chain_id): + if chain and chain.is_midi() and chain != self.chain and chain.midi_chan < 16: + if self.zyngui.chain_manager.will_midi_howl(self.zyngui.chain_manager.active_chain.chain_id, chain_id): prefix = "∞ " else: prefix = "" @@ -289,6 +314,14 @@ def natural_keys(t): def select_action(self, i, t='S'): if t == 'S': action = self.list_data[i][0] + if action == "MIDI Channel": + self.zyngui.screens['midi_chan'].set_mode("SET", self.chain.midi_chan, chan_all=True) + self.zyngui.show_screen('midi_chan') + return + elif action == "MIDI CC": + self.zyngui.screens['midi_cc'].set_chain(self.chain) + self.zyngui.show_screen('midi_cc') + return wait = 2 # Delay after starting service to allow jack ports to update if action == "stop_jacknetumpd": self.zyngui.state_manager.stop_netump(wait=wait) @@ -345,36 +378,54 @@ def show_options(self): return options = {} if self.input: - options["MIDI Input Mode"] = None - mode_info = "Toggle input mode.\n\n" + screen_title = "MIDI Input Device" + port = zynautoconnect.devices_in[idev] + + options["Options"] = None + opt_info = "Toggle input mode.\n\n" if zynautoconnect.get_midi_in_dev_mode(idev): - title = f"{ZMIP_MODE_ACTIVE} Active mode" + title = f"{ZMIP_ICON_MODE_ACTIVE} Active mode" if lib_zyncore.get_active_midi_chan(): - mode_info += f"{title}. Translate MIDI channel. Send to chains matching active chain's MIDI channel." + opt_info += f"{title}. Translate MIDI channel. Send to chains matching active chain's MIDI channel." else: - mode_info += f"{title}. Translate MIDI channel. Send to active chain only." - options[title] = ["MULTI", [mode_info, "midi_input.png"]] + opt_info += f"{title}. Translate MIDI channel. Send to active chain only." + options[title] = ["MODE_MULTI", [opt_info, "midi_input.png"]] else: - title = f"{ZMIP_MODE_MULTI} Multitimbral mode" - mode_info += f"{title}. Don't translate MIDI channel. Send to chains matching device's MIDI channel." - options[title] = ["ACTI", [mode_info, "midi_input.png"]] + title = f"{ZMIP_ICON_MODE_MULTI} Multitimbral mode" + opt_info += f"{title}. Don't translate MIDI channel. Send to chains matching device's MIDI channel." + options[title] = ["MODE_ACTI", [opt_info, "midi_input.png"]] - options["MIDI System Messages"] = None - mode_info = "Route non real-time system messages from this device.\n\n" + opt_info = "Use this input device for live recording from the step sequencer." + if port.aliases[0] in zynautoconnect.get_zynseq_exclude_ports(): + options[f"\u2610 {ZMIP_ICON_SEQ_EXCL} Sequencer capture"] = [idev, [opt_info, "midi_input.png"]] + else: + options[f"\u2612 {ZMIP_ICON_SEQ_EXCL} Sequencer capture"] = [idev, [opt_info, "midi_input.png"]] + + opt_info = "Sync to MIDI clock from this device.\nIt's an exclusive option that will disable syncing from other devices." + if zynautoconnect.get_ext_clock_zmip() == idev: + title = f"\u2612 {ZMIP_ICON_MIDI_CLOCK} MIDI Clock Source" + options[title] = ["MIDI_CLOCK/OFF", [opt_info, "midi_input.png"]] + else: + title = f"\u2610 {ZMIP_ICON_MIDI_CLOCK} MIDI Clock Source" + options[title] = ["MIDI_CLOCK/ON", [opt_info, "midi_input.png"]] + + """ + opt_info = "Route non real-time system messages from this device.\n\n" if lib_zyncore.zmip_get_flag_system(idev): - title = f"\u2612 {ZMIP_MODE_SYS} Non real-time" - options[title] = ["SYSTEM/OFF", [mode_info, "midi_input.png"]] + title = f"\u2612 {ZMIP_ICON_MIDI_SYS} Non real-time messages" + options[title] = ["MIDI_SYS/OFF", [opt_info, "midi_input.png"]] else: - title = f"\u2610 {ZMIP_MODE_SYS} Non real-time" - options[title] = ["SYSTEM/ON", [mode_info, "midi_input.png"]] + title = f"\u2610 {ZMIP_ICON_MIDI_SYS} Non real-time messages" + options[title] = ["MIDI_SYS/ON", [opt_info, "midi_input.png"]] - mode_info = "Route real-time system messages from this device.\n\n" + opt_info = "Route real-time system messages from this device.\n\n" if lib_zyncore.zmip_get_flag_system_rt(idev): - title = f"\u2612 {ZMIP_MODE_SYS_RT} Transport" - options[title] = ["SYSTEM_RT/OFF", [mode_info, "midi_input.png"]] + title = f"\u2612 {ZMIP_ICON_MIDI_SYS_RT} Real-time transport messages" + options[title] = ["MIDI_SYS_RT/OFF", [opt_info, "midi_input.png"]] else: - title = f"\u2610 {ZMIP_MODE_SYS_RT} Transport" - options[title] = ["SYSTEM_RT/ON", [mode_info, "midi_input.png"]] + title = f"\u2610 {ZMIP_ICON_MIDI_SYS_RT} Real-time transport messages" + options[title] = ["MIDI_SYS_RT/ON", [opt_info, "midi_input.png"]] + """ # Reload drivers => Hot reload the driver classes! #self.zyngui.state_manager.ctrldev_manager.update_available_drivers(reload_modules=False) @@ -402,42 +453,54 @@ def show_options(self): if not driver_description: driver_description = "Device driver integrating UI functions and customized workflow." if idev in loaded_drivers and type(loaded_drivers[idev]) is driver_class: - driver_options[f"\u2612 {ZMIP_MODE_CONTROLLER} {driver_name}"] = [ + driver_options[f"\u2612 {ZMIP_ICON_CTRLDEV_DRIVER} {driver_name}"] = [ ["UNLOAD_DRIVER", driver_class.__name__], [driver_description, "midi_input.png"]] else: - driver_options[f"\u2610 {ZMIP_MODE_CONTROLLER} {driver_name}"] = [ + driver_options[f"\u2610 {ZMIP_ICON_CTRLDEV_DRIVER} {driver_name}"] = [ ["LOAD_DRIVER", driver_class.__name__], [driver_description, "midi_input.png"]] if driver_options: - options["Controller Drivers"] = None + options["Drivers"] = None options.update(driver_options) - port = zynautoconnect.devices_in[idev] - else: + screen_title = "MIDI Output Device" port = zynautoconnect.devices_out[idev] + options["Options"] = None + opt_info = "Send MIDI clock to this device." + if port.aliases[0] in zynautoconnect.get_midi_clock_output_ports(): + options[f"\u2612 {ZMIP_ICON_MIDI_CLOCK} Send MIDI Clock"] = [idev, [opt_info, "midi_output.png" ]] + else: + options[f"\u2610 {ZMIP_ICON_MIDI_CLOCK} Send MIDI Clock"] = [idev, [opt_info, "midi_output.png" ]] - options["Configuration"] = None + options["Advanced"] = None if self.list_data[self.index][0].startswith("AUBIO:") or self.list_data[self.index][0].endswith("aubionotes"): options["Select aubio inputs"] = ["AUBIO_INPUTS", ["Select audio inputs to be analized and converted to MIDI.", "midi_audio.png"]] options[f"Rename port '{port.aliases[0]}'"] = [port, ["Rename the MIDI port.\nClear name to reset to default name.", "midi_input.png"]] # options[f"Reset name to '{zynautoconnect.build_midi_port_name(port)[1]}'"] = port - self.zyngui.screens['option'].config("MIDI Input Device", options, self.menu_cb, False, False, None) + self.zyngui.screens['option'].config(screen_title, options, self.menu_cb, False, True, None) self.zyngui.show_screen('option') except Exception as e: - #logging.error(e) - pass # Port may have disappeared whilst building menu + logging.error(e) + #pass # Port may have disappeared whilst building menu - def menu_cb(self, option, params): + def menu_cb(self, option, params, click_type): try: if option.startswith("Rename port"): self.zyngui.show_keyboard(self.rename_device, params.aliases[1]) return elif option.startswith("Reset name"): zynautoconnect.set_port_friendly_name(params) + elif option.endswith("Send MIDI Clock"): + zynautoconnect.toggle_midi_clock_output_zmop(params) + elif option.endswith("Sequencer capture"): + zynautoconnect.toggle_zynseq_input_zmop(params) elif isinstance(params, list): idev = self.list_data[self.index][1] - if params[0] == "LOAD_DRIVER": + if click_type == "B": + self.show_controller_options(idev) + return + elif params[0] == "LOAD_DRIVER": #logging.debug(f"LOAD DRIVER FOR {idev}") self.zyngui.state_manager.ctrldev_manager.load_driver(idev, params[1]) elif params[0] == "UNLOAD_DRIVER": @@ -452,26 +515,64 @@ def menu_cb(self, option, params): elif self.input: idev = self.list_data[self.index][1] match params: - case "SYSTEM/ON": - lib_zyncore.zmip_set_flag_system(idev, True) - case "SYSTEM/OFF": - lib_zyncore.zmip_set_flag_system(idev, False) - case "SYSTEM_RT/ON": - lib_zyncore.zmip_set_flag_system_rt(idev, True) - case "SYSTEM_RT/OFF": - lib_zyncore.zmip_set_flag_system_rt(idev, False) - case "ACTI": + case "MODE_ACTI": lib_zyncore.zmip_set_flag_active_chain(idev, True) zynautoconnect.update_midi_in_dev_mode(idev) - case "MULTI": + case "MODE_MULTI": lib_zyncore.zmip_set_flag_active_chain(idev, False) zynautoconnect.update_midi_in_dev_mode(idev) + case "MIDI_CLOCK/ON": + zynautoconnect.set_ext_clock_zmip(idev) + case "MIDI_CLOCK/OFF": + zynautoconnect.set_ext_clock_zmip(-1) + """ + case "MIDI_SYS/ON": + lib_zyncore.zmip_set_flag_system(idev, True) + case "MIDI_SYS/OFF": + lib_zyncore.zmip_set_flag_system(idev, False) + case "MIDI_SYS_RT/ON": + lib_zyncore.zmip_set_flag_system_rt(idev, True) + case "MIDI_SYS_RT/OFF": + lib_zyncore.zmip_set_flag_system_rt(idev, False) + """ self.show_options() self.update_list() except Exception as e: #logging.error(e) pass # Ports may have changed since menu opened + def show_controller_options(self, idev): + """ Show hardware controller options view + Params: + idev: Index of controller's MIDI port + """ + + # TODO: Check what modes controller supports + try: + options = {} + # Scroll options + mode = self.zyngui.state_manager.ctrldev_manager.drivers[idev].get_scroll_mode() + if mode > 0: + options["Scroll Modes"] = None + options["Locked (no scroll)"] = ("scroll_mode", idev, SCROLL_MODE_FIXED) + options["Follow GUI selection"] = ("scroll_mode", idev, SCROLL_MODE_GUI_SEL) + options["Follow GUI view"] = ("scroll_mode", idev, SCROLL_MODE_GUI_VIEW) + options["Driver custom"] = ("scroll_mode", idev, SCROLL_MODE_CTRLDEV) + if options: + self.zyngui.screens['option'].config("Controller Options", options, self.controller_options_cb, index=mode+1) + self.zyngui.show_screen('option') + except: + pass + + def controller_options_cb(self, option, params): + cmd, idev, val = params + try: + if cmd == "scroll_mode": + self.zyngui.state_manager.ctrldev_manager.drivers[idev].set_scroll_mode(cal) + except: + logging.warning(f"Failed to set ctrldev option for device {idev}: {cmd}({val}) ") + self.show_options() + def process_dynamic_ports(self): """Process dynamically added/removed MIDI devices""" diff --git a/zyngui/zynthian_gui_midi_key_range.py b/zyngui/zynthian_gui_midi_key_range.py index 9246e2133..4d0e2f5ea 100644 --- a/zyngui/zynthian_gui_midi_key_range.py +++ b/zyngui/zynthian_gui_midi_key_range.py @@ -30,7 +30,7 @@ from zyngine import zynthian_controller from zyngui import zynthian_gui_config from zyngui.zynthian_gui_base import zynthian_gui_base -from zyngui.zynthian_gui_selector import zynthian_gui_controller +from zyngui.zynthian_gui_controller import zynthian_gui_controller # ------------------------------------------------------------------------------ # Zynthian MIDI key-range GUI Class @@ -76,16 +76,14 @@ def __init__(self): self.zctrl_pos = [0, 2, 1, 3] self.main_frame.columnconfigure(1, weight=1) else: - self.spacer.grid(row=0, column=0, rowspan=2, - padx=(0, 2), sticky='news') + self.spacer.grid(row=0, column=0, rowspan=2,padx=(0, 2), sticky='news') self.zctrl_pos = [0, 1, 3, 2] self.main_frame.columnconfigure(0, weight=1) self.note_info_frame = tkinter.Frame(self.main_frame, bg=zynthian_gui_config.color_panel_bg) self.note_info_frame.columnconfigure(1, weight=1) - self.note_info_frame.grid( - row=2, columnspan=3, sticky="nsew", pady=(2, 2)) + self.note_info_frame.grid(row=2, columnspan=3, sticky="nsew", pady=(2, 2)) # Piano canvas self.piano_canvas = tkinter.Canvas(self.main_frame, @@ -110,10 +108,8 @@ def config(self, chain): self.zmop_index = self.chain.zmop_index self.note_low = lib_zyncore.zmop_get_note_low(self.zmop_index) self.note_high = lib_zyncore.zmop_get_note_high(self.zmop_index) - self.octave_trans = lib_zyncore.zmop_get_transpose_octave( - self.zmop_index) - self.halftone_trans = lib_zyncore.zmop_get_transpose_semitone( - self.zmop_index) + self.octave_trans = lib_zyncore.zmop_get_transpose_octave(self.zmop_index) + self.halftone_trans = lib_zyncore.zmop_get_transpose_semitone(self.zmop_index) else: self.zmop_index = None self.set_select_path() @@ -136,8 +132,7 @@ def plot_piano(self): bgcolor = "#D0D0D0" else: bgcolor = "#FFFFFF" - key = self.piano_canvas.create_rectangle( - (x1, 0, x2, self.piano_canvas_height), fill=bgcolor, width=0) + key = self.piano_canvas.create_rectangle((x1, 0, x2, self.piano_canvas_height), fill=bgcolor, width=0) self.piano_canvas.tag_lower(key) midi_note += 1 self.piano_keys.append(key) @@ -155,8 +150,7 @@ def plot_piano(self): bgcolor = "#707070" else: bgcolor = "#000000" - key = self.piano_canvas.create_rectangle( - (x1b, 0, x2b, black_height), fill=bgcolor, width=0) + key = self.piano_canvas.create_rectangle((x1b, 0, x2b, black_height), fill=bgcolor, width=0) midi_note += 1 self.piano_keys.append(key) # logging.debug("PLOTTING PIANO BLACK KEY {}: {}".format(midi_note,x1)) @@ -181,11 +175,9 @@ def update_piano(self): bgcolor = "#707070" else: bgcolor = "#000000" - self.piano_canvas.itemconfig(self.piano_keys[j], fill=bgcolor) j += 1 midi_note += 1 - i += 1 @staticmethod @@ -202,8 +194,7 @@ def plot_text(self): self.nlow_text = tkinter.Label(self.note_info_frame, fg=zynthian_gui_config.color_ctrl_tx, bg=zynthian_gui_config.color_panel_bg, - font=( - zynthian_gui_config.font_family, fs), + font=(zynthian_gui_config.font_family, fs), width=5, text=self.get_midi_note_name(self.note_low)) self.nlow_text.grid(row=0, column=0, sticky='nsw') @@ -213,8 +204,7 @@ def plot_text(self): self.nhigh_text = tkinter.Label(self.note_info_frame, fg=zynthian_gui_config.color_ctrl_tx, bg=zynthian_gui_config.color_panel_bg, - font=( - zynthian_gui_config.font_family, fs), + font=(zynthian_gui_config.font_family, fs), width=5, text=self.get_midi_note_name(self.note_high)) self.nhigh_text.grid(row=0, column=2, sticky='sne') @@ -224,13 +214,11 @@ def plot_text(self): self.learn_text = tkinter.Label(self.note_info_frame, fg='Dark Grey', bg=zynthian_gui_config.color_panel_bg, - font=( - zynthian_gui_config.font_family, int(fs*0.6)), + font=(zynthian_gui_config.font_family, int(fs*0.6)), text='not learning', width=1) self.learn_text.grid(row=0, column=1, sticky='nsew') - self.learn_text.bind("", - lambda e: self.zyngui.cuia_toggle_midi_learn()) + self.learn_text.bind("", lambda e: self.zyngui.cuia_toggle_midi_learn()) def update_text(self): self.nlow_text['text'] = self.get_midi_note_name(self.note_low) @@ -239,10 +227,8 @@ def update_text(self): def set_zctrls(self): if not self.octave_zgui_ctrl: i = zynthian_gui_config.layout['ctrl_order'][0] - self.octave_zctrl = zynthian_controller( - self, 'octave transpose', {'value_min': -5, 'value_max': 6}) - self.octave_zgui_ctrl = zynthian_gui_controller( - i, self.main_frame, self.octave_zctrl) + self.octave_zctrl = zynthian_controller(self, 'octave transpose', {'value_min': -5, 'value_max': 6}) + self.octave_zgui_ctrl = zynthian_gui_controller(i, self.main_frame, self.octave_zctrl) self.zgui_ctrls[i] = self.octave_zgui_ctrl self.octave_zgui_ctrl.setup_zynpot() self.octave_zgui_ctrl.erase_midi_bind() @@ -250,10 +236,8 @@ def set_zctrls(self): if not self.halftone_zgui_ctrl: i = zynthian_gui_config.layout['ctrl_order'][1] - self.halftone_zctrl = zynthian_controller( - self, 'semitone transpose', {'value_min': -12, 'value_max': 12}) - self.halftone_zgui_ctrl = zynthian_gui_controller( - i, self.main_frame, self.halftone_zctrl) + self.halftone_zctrl = zynthian_controller(self, 'semitone transpose', {'value_min': -12, 'value_max': 12}) + self.halftone_zgui_ctrl = zynthian_gui_controller(i, self.main_frame, self.halftone_zctrl) self.zgui_ctrls[i] = self.halftone_zgui_ctrl self.halftone_zgui_ctrl.setup_zynpot() self.halftone_zgui_ctrl.erase_midi_bind() @@ -261,36 +245,28 @@ def set_zctrls(self): if not self.nlow_zgui_ctrl: i = zynthian_gui_config.layout['ctrl_order'][2] - self.nlow_zctrl = zynthian_controller( - self, 'note low', {'nudge_factor': 1}) - self.nlow_zgui_ctrl = zynthian_gui_controller( - i, self.main_frame, self.nlow_zctrl, hidden=True) + self.nlow_zctrl = zynthian_controller(self, 'note low', {'nudge_factor': 1}) + self.nlow_zgui_ctrl = zynthian_gui_controller(i, self.main_frame, self.nlow_zctrl, hidden=True) self.zgui_ctrls[i] = self.nlow_zgui_ctrl self.nlow_zgui_ctrl.setup_zynpot() self.nlow_zctrl.set_value(self.note_low) if not self.nhigh_zgui_ctrl: i = zynthian_gui_config.layout['ctrl_order'][3] - self.nhigh_zctrl = zynthian_controller( - self, 'note high', {'nudge_factor': 1}) - self.nhigh_zgui_ctrl = zynthian_gui_controller( - i, self.main_frame, self.nhigh_zctrl, hidden=True) + self.nhigh_zctrl = zynthian_controller(self, 'note high', {'nudge_factor': 1}) + self.nhigh_zgui_ctrl = zynthian_gui_controller(i, self.main_frame, self.nhigh_zctrl, hidden=True) self.zgui_ctrls[i] = self.nhigh_zgui_ctrl self.nhigh_zgui_ctrl.setup_zynpot() self.nhigh_zctrl.set_value(self.note_high) if zynthian_gui_config.layout['columns'] == 3: - self.octave_zgui_ctrl.configure( - height=self.height // 2, width=self.width // 4) - self.halftone_zgui_ctrl.configure( - height=self.height // 2, width=self.width // 4) + self.octave_zgui_ctrl.configure(height=self.height // 2, width=self.width // 4) + self.halftone_zgui_ctrl.configure(height=self.height // 2, width=self.width // 4) self.octave_zgui_ctrl.grid(row=0, column=0) self.halftone_zgui_ctrl.grid(row=0, column=2) else: - self.octave_zgui_ctrl.configure( - height=self.height // 4, width=self.width // 4) - self.halftone_zgui_ctrl.configure( - height=self.height // 4, width=self.width // 4) + self.octave_zgui_ctrl.configure(height=self.height // 4, width=self.width // 4) + self.halftone_zgui_ctrl.configure(height=self.height // 4, width=self.width // 4) self.octave_zgui_ctrl.grid(row=0, column=2, pady=(0, 1)) self.halftone_zgui_ctrl.grid(row=1, column=2, pady=(1, 0)) @@ -363,24 +339,19 @@ def send_controller_value(self, zctrl): if zctrl.value < self.nlow_zctrl.value: self.nhigh_zctrl.set_value(self.nlow_zctrl.value + 1) lib_zyncore.zmop_set_note_high(self.zmop_index, zctrl.value) - logging.debug( - "SETTING RANGE NOTE HIGH: {}".format(zctrl.value)) + logging.debug("SETTING RANGE NOTE HIGH: {}".format(zctrl.value)) self.replot = True elif zctrl == self.octave_zctrl: self.octave_trans = zctrl.value - lib_zyncore.zmop_set_transpose_octave( - self.zmop_index, zctrl.value) - logging.debug( - "SETTING OCTAVE TRANSPOSE: {}".format(zctrl.value)) + lib_zyncore.zmop_set_transpose_octave(self.zmop_index, zctrl.value) + logging.debug("SETTING OCTAVE TRANSPOSE: {}".format(zctrl.value)) self.replot = True elif zctrl == self.halftone_zctrl: self.halftone_trans = zctrl.value - lib_zyncore.zmop_set_transpose_semitone( - self.zmop_index, zctrl.value) - logging.debug( - "SETTING SEMITONE TRANSPOSE: {}".format(zctrl.value)) + lib_zyncore.zmop_set_transpose_semitone(self.zmop_index, zctrl.value) + logging.debug("SETTING SEMITONE TRANSPOSE: {}".format(zctrl.value)) self.replot = True def learn_note_range(self, num): diff --git a/zyngui/zynthian_gui_midi_recorder.py b/zyngui/zynthian_gui_midi_recorder.py index 90cb12bdf..d32910309 100644 --- a/zyngui/zynthian_gui_midi_recorder.py +++ b/zyngui/zynthian_gui_midi_recorder.py @@ -234,7 +234,7 @@ def show_menu(self): def toggle_menu(self): if self.shown: self.show_menu() - elif self.zyngui.current_screen == "option": + elif self.zyngui.get_current_screen() == "option": self.close_screen() def smf_options_cb(self, option, smf): @@ -276,7 +276,6 @@ def show_playing_bpm(self): self.zgui_ctrl2.hide() self.zgui_ctrl2.config(self.zyngui.state_manager.zynseq.zctrl_tempo) self.zgui_ctrl2.show() - self.zyngui.state_manager.zynseq.update_tempo() def hide_playing_bpm(self): self.zgui_ctrl2.hide() diff --git a/zyngui/zynthian_gui_mixer.py b/zyngui/zynthian_gui_mixer.py index 46e217854..f4ef17819 100644 --- a/zyngui/zynthian_gui_mixer.py +++ b/zyngui/zynthian_gui_mixer.py @@ -5,7 +5,7 @@ # # Zynthian GUI Audio Mixer # -# Copyright (C) 2015-2024 Fernando Moyano +# Copyright (C) 2015-2026 Fernando Moyano # Brian Walton # # ****************************************************************************** @@ -24,23 +24,346 @@ # # ****************************************************************************** -import os + import tkinter import logging -from time import monotonic +#import traceback from math import log10 +from time import monotonic +from threading import Timer +from PIL import Image, ImageTk +from os.path import basename, splitext # Zynthian specific modules from zyncoder.zyncore import lib_zyncore +from zynlibs.zynseq import zynseq from zynlibs.zynaudioplayer import * -from zyngine.zynthian_signal_manager import zynsigman - -from . import zynthian_gui_base -from . import zynthian_gui_config +from zynlibs.zynmixer.zynmixer import SS_ZYNMIXER_SET_VALUE +from zyngui import zynthian_gui_config +from zyngui.zynthian_gui_base import zynthian_gui_base from zyngui.zynthian_gui_dpm import zynthian_gui_dpm +from zyngine.zynthian_signal_manager import zynsigman from zyngine.zynthian_audio_recorder import zynthian_audio_recorder from zyngine.zynthian_engine_audioplayer import zynthian_engine_audioplayer -from zyngine import zynthian_controller + +logging.getLogger('PIL').setLevel(logging.WARNING) + + +# -------------------------------------------------------------- +# Zynthian sequence launcher button class +# This provides a UI element that represents a launcher button +# -------------------------------------------------------------- + +DRAG_THRESHOLD = 5 + +class zynthian_gui_launcher_pad(): + + def __init__(self, parent, canvas, x, y, width, height, chain, phrase): + logging.getLogger('PIL').setLevel(logging.WARNING) + """ Initialise mixer strip object + args: + parent: Parent object (zyngui_mixer) + canvas: Canvas to draw onto + x: Horizontal coordinate of left of launcher + y: Vertical coordinate of top of launcher + width: Width of launcher + height: Height of launcher + chain: Chain object for the strip that contains this launcher + phrase: Phrase (row) index + """ + + self.gui_mixer = parent + self.canvas = canvas + self.x = x + self.y = y + self.height = height + self.width = width + self.chain = chain + self.phrase = phrase + + id = self.chain.chain_id + tags = ("launcher", f"strip_{id}", f"launcher_{id}_{phrase}") + # Launcher pad (background) + self.pad = self.canvas.create_rectangle(x, y, x + self.width - 1, y + self.height - 1, + width=3, + fill=zynthian_gui_config.color_panel_bg, + tags=(*tags, "launcher_pad")) + # Play state text + self.play_state = self.canvas.create_text(x + self.width - 3, y - 3, text="", + anchor=tkinter.NE, + font=self.gui_mixer.font_clip_state, + tags=(*tags, "launcher_play_state")) + # Title text + self.title = self.canvas.create_text(x + self.width // 2, y + 0.5 * self.height, text="", + anchor=tkinter.CENTER, + font=self.gui_mixer.font_clip_title, + fill=self.gui_mixer.legend_txt_color, + tags=(*tags, "launcher_title")) + # Play mode image + self.mode_icon = self.canvas.create_image(x + 3, y + 2, + anchor=tkinter.NW, + tags=(*tags, "launcher_mode_icon")) + # Play mode text + self.mode_text = self.canvas.create_text(x + 3, y - 3, + anchor=tkinter.NW, + fill=self.gui_mixer.legend_txt_color, + font=self.gui_mixer.font_clip_state, + tags=(*tags, "launcher_mode_text")) + # Timesig text + self.timesig = self.canvas.create_text(x + 3, y + self.height, + anchor=tkinter.SW, + fill=self.gui_mixer.legend_txt_color, + font=self.gui_mixer.font_timebase, + tags=(*tags, "launcher_timesig")) + # Tempo text + self.tempo = self.canvas.create_text(x + self.width - 1, y + self.height, + anchor=tkinter.SE, + fill=self.gui_mixer.legend_txt_color, + justify=tkinter.RIGHT, + font=self.gui_mixer.font_timebase, + tags=(*tags, "launcher_tempo")) + + self.canvas.tag_bind(f"launcher_{id}_{phrase}", '', self.on_clip_release) + + def highlight(self): + """ Show selection cursor highlight""" + + self.canvas.itemconfig(self.pad, outline="yellow") + + def get_pattern_length(self, beats, bpb): + if not bpb: + bpb = 4 + if bpb > 1: + bars = beats // bpb + else: + bars = 0 + extra_beats = beats % bpb + if extra_beats == 0: + beats_text = "" + else: + beats_text = f"{extra_beats}♩" + if bars == 0: + bars_text = "" + else: + bars_text = f"{bars}" + if bars and extra_beats: + return f"{bars_text} + {beats_text}" + else: + return bars_text + beats_text + + def draw(self): + """ Update the launcher button elements""" + + mode_image = None + mode_text = "" + timesig_text = "" + tempo_text = "" + color_text = self.gui_mixer.legend_txt_color + try: + state_phrase = self.gui_mixer.zynseq.state["scenes"][self.gui_mixer.zynseq.scene]["phrases"][self.phrase] + if self.chain.chain_id == 0: + state_seq = state_phrase + elif self.chain.midi_chan is None or self.chain.midi_chan > 31: + state_seq = None # This will raise an exception later and draw empty block + else: + state_seq = state_phrase["sequences"][self.chain.midi_chan] + + name = state_seq["name"] + # If not asigned name => generate default name on-the-fly + if not name: + name = chr(ord('A') + self.phrase) + if self.chain.chain_id > 0: + name += str(self.chain.midi_chan + 1) # QUESTION: It MIDI chan same than group? + + disabled = state_seq["repeat"] == 0 + empty = False + + # Moving phrase + if self.gui_mixer.moving_phrase and self.phrase == self.gui_mixer.zynseq.phrase: + if self.phrase == 0: + title = f"⇓ {name[:8]}" + elif self.phrase == self.gui_mixer.zynseq.phrases - 1: + title = f"⇑ {name[:8]}" + else: + title = f"⇕ {name[:8]}" + # Normal draw + else: + title = name[:8] + + # Chain launcher => + if self.chain.chain_id: + # Zynstep pattern + if state_seq["group"] < 16: + try: + pattern = state_seq["tracks"][0]["patns"]["0"] + n_beats = self.gui_mixer.zynseq.libseq.getBeatsInPattern(pattern) + timesig_text = self.get_pattern_length(n_beats, state_phrase["bpb"]) + try: + empty = len(self.gui_mixer.zynseq.state["patns"][str(pattern)]["events"]) == 0 + except: + empty = True + except Exception as e: + logging.error(e) + disabled = True + # Clippy + else: + # TODO => Fix this!! + timesig_text = "1" + empty = False + + match state_seq["followAction"]: + case zynseq.FOLLOW_ACTION_NONE: + if state_seq["repeat"] <= 1: + mode_text = "↦" + elif state_seq["repeat"] > 1: + mode_text = "x" + str(state_seq["repeat"]) + case zynseq.FOLLOW_ACTION_RELATIVE: + if state_seq["followParam"] == 0: + mode_text = "↻" + else: + mode_text = "→" + case _: + mode_text = "→" + + # Launcher background color + if empty: + color = zynthian_gui_config.PAD_COLOUR_EMPTY + else: + color = zynthian_gui_config.LAUNCHER_COLOUR[state_seq["group"]]["rgb"] + + # Phrase launcher => + else: + color = zynthian_gui_config.PAD_COLOUR_PHRASE + if state_seq["repeat"]: + if state_seq["repeat"] == 255: + mode_text = "a" + else: + mode_text = f"{state_seq['repeat']}" + else: + #title = "⏹" + pass + + match state_seq["followAction"]: + case zynseq.FOLLOW_ACTION_NONE: + #mode_text += "→" + pass + case zynseq.FOLLOW_ACTION_RELATIVE: + offset = state_seq["followParam"] + if offset < -1: + mode_text += f"↑{-offset}" + elif offset == -1: + mode_text += f"↑" + elif offset == 1: + mode_text += f"↓" + elif offset > 1: + mode_text += f"↓{offset}" + else: + mode_text = "↻" + case _: + #mode_text += "↦" + mode_text += "" + + if "bpb" in state_seq: + sig = state_seq["bpb"] + if sig: + timesig_text = f"{state_seq['bpb']}/4" + if "tempo" in state_seq: + tempo = state_seq["tempo"] + if tempo: + tempo_text = f"{tempo:.1f}" + + if disabled: + color = zynthian_gui_config.PAD_COLOUR_DISABLED + color_text = zynthian_gui_config.PAD_COLOUR_STATE_DISABLED + color_state = zynthian_gui_config.PAD_COLOUR_STATE_DISABLED + state_text = "" + else: + # Play state + match state_seq["state"]: + case zynseq.SEQ_PLAYING: + color_state = zynthian_gui_config.PAD_COLOUR_PLAYING + state_text = "▶" + case zynseq.SEQ_STARTING: + color_state = zynthian_gui_config.PAD_COLOUR_STARTING + state_text = "▶" + case zynseq.SEQ_STOPPING: + color_state = zynthian_gui_config.PAD_COLOUR_STOPPING + state_text = "▶" + case zynseq.SEQ_STOPPING_SYNC: + color_state = zynthian_gui_config.PAD_COLOUR_STOPPING + state_text = "▶" + case zynseq.SEQ_CHILD_PLAYING: + color_state = zynthian_gui_config.PAD_COLOUR_STOPPED + state_text = "▶" + case zynseq.SEQ_CHILD_STOPPING: + color_state = zynthian_gui_config.PAD_COLOUR_STOPPING + state_text = "▶" + case zynseq.SEQ_STOPPED: + color_state = zynthian_gui_config.PAD_COLOUR_STOPPED + state_text = "⏹" + case _: + color_text = zynthian_gui_config.PAD_COLOUR_STATE_DISABLED + color_state = zynthian_gui_config.PAD_COLOUR_STATE_DISABLED + state_text = "?" + except: + #logging.exception(traceback.format_exc()) + title = "" + color = zynthian_gui_config.PAD_COLOUR_DISABLED + color_text = zynthian_gui_config.PAD_COLOUR_STATE_DISABLED + color_state = zynthian_gui_config.PAD_COLOUR_STATE_DISABLED + state_text = "?" + + self.canvas.itemconfig(self.pad, fill=color) + self.canvas.itemconfig(self.title, text=title, fill=color_text) + self.canvas.itemconfig(self.play_state, text=state_text, fill=color_state) + if self.chain.chain_id: + # Chain sequence launcher + self.canvas.itemconfig(self.mode_text, text=mode_text, fill=color_text, state=tkinter.NORMAL) + self.canvas.itemconfig(self.timesig, text=timesig_text, fill=color_text, state=tkinter.NORMAL) + self.canvas.itemconfig(self.tempo, state=tkinter.HIDDEN) + self.canvas.itemconfig(self.mode_icon, state=tkinter.HIDDEN) + else: + # Phrase launcher + self.canvas.itemconfig(self.mode_text, text=mode_text, fill=color_text, state=tkinter.NORMAL) + self.canvas.itemconfig(self.timesig, text=timesig_text, fill=color_text, state=tkinter.NORMAL) + self.canvas.itemconfig(self.tempo, text=tempo_text, fill=color_text, state=tkinter.NORMAL) + self.canvas.itemconfig(self.mode_icon, state=tkinter.HIDDEN) + + def on_clip_release(self, event): + if not self.gui_mixer.press_event or self.gui_mixer.dragging: + return + ts = event.time - self.gui_mixer.press_event.time + ts /= 1000.0 + self.gui_mixer.select_launcher(self.phrase) + self.gui_mixer.update_active_chain(self.chain.chain_id, True) + if ts < zynthian_gui_config.zynswitch_bold_seconds: + self.on_clip_short_press() + elif ts < zynthian_gui_config.zynswitch_long_seconds: + self.on_clip_bold_press() + else: + self.on_clip_long_press() + + def on_clip_short_press(self): + if self.chain.chain_id: + midi_chan = self.chain.midi_chan + else: + midi_chan = 32 + if midi_chan is None or midi_chan > 32: + return + self.gui_mixer.zynseq.libseq.togglePlayState(self.gui_mixer.zynseq.scene, self.phrase, midi_chan) + + def on_clip_bold_press(self): + if self.chain.chain_id: + midi_chan = self.chain.midi_chan + else: + midi_chan = 32 + if midi_chan is None or midi_chan > 32: + return + self.gui_mixer.edit_clip() + + def on_clip_long_press(self): + self.gui_mixer.edit_clip() + # ------------------------------------------------------------------------------ # Zynthian Mixer Strip Class @@ -50,429 +373,299 @@ class zynthian_gui_mixer_strip(): - def __init__(self, parent, x, y, width, height): + def __init__(self, parent, canvas, x, width, height, chain): + logging.getLogger('PIL').setLevel(logging.WARNING) """ Initialise mixer strip object - parent: Parent object - x: Horizontal coordinate of left of fader - y: Vertical coordinate of top of fader - width: Width of fader - height: Height of fader + args: + parent: Parent object (zyngui_mixer) + canvas: Canvas to draw onto + x: Horizontal coordinate of left of fader + width: Width of fader + height: Height of fader + chain: Chain object for this mixer strip """ - self.parent = parent - self.zynmixer = parent.zynmixer - self.zctrls = None + + self.canvas = canvas + self.gui_mixer = parent + self.zyngui = parent.zyngui + self.state_manager = self.zyngui.state_manager + self.chain_manager = self.zyngui.chain_manager + self.zynseq = self.state_manager.zynseq self.x = x - self.y = y self.width = width self.height = height - self.hidden = False - self.chain_id = None - self.chain = None - self.hidden = True - - self.button_height = int(self.height * 0.07) - self.legend_height = int(self.height * 0.08) - self.balance_height = int(self.height * 0.03) - self.fader_height = self.height - self.balance_height - \ - self.legend_height - 2 * self.button_height - self.fader_bottom = self.height - self.legend_height - self.balance_height - self.fader_top = self.fader_bottom - self.fader_height - self.fader_centre_x = int(x + width * 0.5) - self.fader_centre_y = int(y + height * 0.5) - self.balance_top = self.fader_bottom - self.balance_control_centre = int(self.width / 2) - # Width of each half of balance control - self.balance_control_width = int(self.width / 4) + self.chain = chain + if self.chain.chain_id == 0: + self.chan = 32 + else: + self.chan = self.chain.midi_chan + + self.button_height = self.gui_mixer.button_height + self.legend_height = self.gui_mixer.legend_height + self.balance_height = self.gui_mixer.balance_height + self.balance_width = (self.width - 2) / 2 # Width of each half of the balance indicator + self.solo_y = parent.solo_y + self.mute_y = parent.mute_y + self.balance_y = parent.balance_y + self.fader_y = parent.fader_y + self.legend_y = parent.legend_y + self.centre_x = x + int(self.width * 0.5) + self.fader_text_limit = int(0.95 * self.gui_mixer.fader_height) + self.dragging = False # Digital Peak Meter (DPM) parameters - self.dpm_width = int(self.width / 10) # Width of each DPM - self.dpm_length = self.fader_height - self.dpm_y0 = self.fader_top + if zynthian_gui_config.enable_dpm or self.chain.chain_id == 0: + self.dpm_width = int(self.width / 13) # Width of each DPM + else: + self.dpm_width = 0 + self.dpm_length = self.gui_mixer.fader_height + self.dpm_y0 = self.fader_y self.dpm_a_x0 = x + self.width - self.dpm_width * 2 - 2 self.dpm_b_x0 = x + self.width - self.dpm_width - 1 self.fader_width = self.width - self.dpm_width * 2 - 2 - self.fader_drag_start = None - self.strip_drag_start = None - self.dragging = False - - # Default style - # self.fader_bg_color = zynthian_gui_config.color_bg - self.fader_bg_color = zynthian_gui_config.color_panel_bg - self.fader_bg_color_hl = "#6a727d" # "#207024" - # self.fader_color = zynthian_gui_config.color_panel_hl - # self.fader_color_hl = zynthian_gui_config.color_low_on - self.fader_color = zynthian_gui_config.color_off - self.fader_color_hl = zynthian_gui_config.color_on - self.legend_txt_color = zynthian_gui_config.color_tx - self.legend_bg_color = zynthian_gui_config.color_panel_bg - self.legend_bg_color_hl = zynthian_gui_config.color_on - self.button_bgcol = zynthian_gui_config.color_panel_bg - self.button_txcol = zynthian_gui_config.color_tx - self.left_color = "#00AA00" - self.right_color = "#00EE00" - self.learn_color_hl = "#999999" - self.learn_color = "#777777" - self.high_color = "#CCCC00" # yellow - self.rec_color = "#CC0000" # red - - self.mute_color = zynthian_gui_config.color_on # "#3090F0" - self.solo_color = "#D0D000" - self.mono_color = "#B0B0B0" - - # font_size = int(0.5 * self.legend_height) - font_size = int(0.25 * self.width) - self.font = (zynthian_gui_config.font_family, font_size) - self.font_fader = (zynthian_gui_config.font_family, - int(0.9 * font_size)) - self.font_icons = ("forkawesome", int(0.3 * self.width)) - self.font_learn = (zynthian_gui_config.font_family, - int(0.7 * font_size)) - - self.fader_text_limit = self.fader_top + int(0.1 * self.fader_height) - - """ - Create GUI elements - Tags: - strip:X All elements within the fader strip used to show/hide strip - fader:X Elements used for fader drag - X is the id of this fader's background - """ + self.fader_press_event = None + self.launchers = [] # List of launcher button objects, indexed by phrase + + #Create GUI elements + id = self.chain.chain_id + + # Block background to hide scrolling launchers, etc. + self.audio_bg = self.canvas.create_rectangle(x, self.solo_y, x + self.width, parent.launcher_y, fill=self.gui_mixer.button_bgcol, width=0) + # Fader background defines height of fader + self.fader_bg = self.canvas.create_rectangle(x, self.fader_y, x + self.width, self.legend_y, fill=self.gui_mixer.fader_bg_color, width=0, tags=("fader", f"fader_{id}")) + # Audio mixer elements + if self.chain.zynmixer_proc: + # Solo button + self.solo = self.canvas.create_rectangle(x, self.solo_y, x + self.width, self.mute_y, fill=self.gui_mixer.button_bgcol, width=0, + tags=(f"solo_{id}",)) + self.solo_text = self.canvas.create_text(x + self.width / 2, self.solo_y + self.button_height * 0.5, text="S", fill=self.gui_mixer.button_txcol, font=self.gui_mixer.font, + tags=(f"solo_{id}",)) + + # Mute button + self.mute = self.canvas.create_rectangle(x, self.mute_y, x + self.width, self.balance_y, fill=self.gui_mixer.button_bgcol, width=0, tags=(f"mute_{id}",)) + self.mute_text = self.canvas.create_text(x + self.width / 2, self.mute_y + self.button_height * 0.5, text="M", fill=self.gui_mixer.button_txcol, font=self.gui_mixer.font, tags=(f"mute_{id}",)) + + # Balance indicator + self.balance_bg = self.canvas.create_rectangle(self.x + 1, self.balance_y, self.x + self.width - 1, self.fader_y, fill=self.gui_mixer.balance_bg_color, width=0, tags=(f"balance_{id}",)) + self.balance_fg = self.canvas.create_rectangle(self.centre_x - 1, self.balance_y, self.centre_x + 1, self.fader_y, fill=self.gui_mixer.balance_fg_color, width=0, tags=(f"balance_{id}",)) + # Fader + self.fader_overlay = self.canvas.create_rectangle(x, self.fader_y, x + self.width, self.legend_y, fill=self.gui_mixer.fader_color, width=0, tags=("fader", "fader_overlay", f"fader_{id}")) + self.fader_horizontal = self.canvas.create_rectangle(x, self.fader_y, x + self.width, self.fader_y + self.balance_height, fill=self.gui_mixer.fader_color, width=0, tags=("fader_horizontal",), state=tkinter.HIDDEN) + + # DPM + if self.chain.chain_id: + dpm_tags = ("dpm") + else: + dpm_tags = ("dpm_0") + self.dpm_bg = self.canvas.create_rectangle(self.dpm_a_x0, self.dpm_y0, self.x + self.width + self.dpm_width, self.dpm_y0 + self.dpm_length, width=0, fill=self.gui_mixer.fader_bg_color) + self.dpm_a = zynthian_gui_dpm(self.canvas, self.dpm_a_x0, self.dpm_y0, self.dpm_width, self.dpm_length, tags=dpm_tags) + self.dpm_b = zynthian_gui_dpm(self.canvas, self.dpm_b_x0, self.dpm_y0, self.dpm_width, self.dpm_length, tags=dpm_tags) - # Fader - self.fader_bg = self.parent.main_canvas.create_rectangle( - x, self.fader_top, x + self.width, self.fader_bottom, fill=self.fader_bg_color, width=0) - self.parent.main_canvas.itemconfig(self.fader_bg, tags=( - f"fader:{self.fader_bg}", f"strip:{self.fader_bg}")) - self.fader = self.parent.main_canvas.create_rectangle(x, self.fader_top, x + self.width, self.fader_bottom, fill=self.fader_color, width=0, tags=( - f"fader:{self.fader_bg}", f"strip:{self.fader_bg}", f"audio_strip:{self.fader_bg}")) - self.fader_text = self.parent.main_canvas.create_text(x, self.fader_bottom - 2, fill=self.legend_txt_color, text="", tags=( - f"fader:{self.fader_bg}", f"strip:{self.fader_bg}"), angle=90, anchor="nw", font=self.font_fader) - - # DPM - self.dpm_a = zynthian_gui_dpm(self.zynmixer, None, 0, self.parent.main_canvas, self.dpm_a_x0, self.dpm_y0, - self.dpm_width, self.fader_height, True, (f"strip:{self.fader_bg}", f"audio_strip:{self.fader_bg}")) - self.dpm_b = zynthian_gui_dpm(self.zynmixer, None, 1, self.parent.main_canvas, self.dpm_b_x0, self.dpm_y0, - self.dpm_width, self.fader_height, True, (f"strip:{self.fader_bg}", f"audio_strip:{self.fader_bg}")) - - self.mono_text = self.parent.main_canvas.create_text(int(self.dpm_b_x0 + self.dpm_width / 2), int( - self.fader_top + self.fader_height / 2), text="??", state=tkinter.HIDDEN) - - # Solo button - self.solo = self.parent.main_canvas.create_rectangle(x, 0, x + self.width, self.button_height, fill=self.button_bgcol, width=0, tags=( - f"solo_button:{self.fader_bg}", f"strip:{self.fader_bg}", f"audio_strip:{self.fader_bg}")) - self.solo_text = self.parent.main_canvas.create_text(x + self.width / 2, self.button_height * 0.5, text="S", fill=self.button_txcol, tags=( - f"solo_button:{self.fader_bg}", f"strip:{self.fader_bg}", f"audio_strip:{self.fader_bg}"), font=self.font) - - # Mute button - self.mute = self.parent.main_canvas.create_rectangle(x, self.button_height, x + self.width, self.button_height * 2, fill=self.button_bgcol, width=0, tags=( - f"mute:{self.fader_bg}", f"strip:{self.fader_bg}", f"audio_strip:{self.fader_bg}")) - self.mute_text = self.parent.main_canvas.create_text(x + self.width / 2, self.button_height * 1.5, text="M", fill=self.button_txcol, tags=( - f"mute:{self.fader_bg}", f"strip:{self.fader_bg}", f"audio_strip:{self.fader_bg}"), font=self.font) + # Chain title + self.fader_text = self.canvas.create_text(x, self.legend_y - 2, fill=self.gui_mixer.legend_txt_color, angle=90, anchor="nw", font=self.gui_mixer.font_fader, text="", + tags=("fader", f"fader_{id}"), justify=tkinter.LEFT) # Legend strip at bottom of screen - self.legend_strip_bg = self.parent.main_canvas.create_rectangle(x, self.height - self.legend_height, x + self.width, self.height, width=0, tags=( - f"strip:{self.fader_bg}", f"legend_strip:{self.fader_bg}"), fill=self.legend_bg_color) - self.legend_strip_txt = self.parent.main_canvas.create_text(self.fader_centre_x, self.height - self.legend_height / 2, - fill=self.legend_txt_color, text="-", tags=(f"strip:{self.fader_bg}", f"legend_strip:{self.fader_bg}"), font=self.font) + if self.chain.chain_id == 0: + tags = ("legend", f"legend_strip_{id}", "legend_strip_main") + elif self.chain.zynmixer_proc and self.chain.zynmixer_proc.eng_code=="MR": + tags = ("legend", f"legend_strip_{id}", "legend_strip_bus") + else: + tags = ("legend", f"legend_strip_{id}") + self.legend_strip_bg = self.canvas.create_rectangle(x, self.gui_mixer.legend_y, x + self.width, self.gui_mixer.legend_y + self.legend_height, width=0, fill=self.gui_mixer.legend_bg_color, tags=tags) + self.legend_strip_txt = self.canvas.create_text(self.centre_x, self.gui_mixer.legend_y + self.legend_height / 2, fill=self.gui_mixer.legend_txt_color, text="-", tags=(f"legend_strip_{id}",), font=self.gui_mixer.font) + + # MIDI pedal indicators self.pedals = [] - for i in range(4): - self.pedals.append(self.parent.main_canvas.create_rectangle( - int(x + self.fader_width / 4 * i), - self.fader_bottom, - int(x + self.fader_width / 4 * (i + 1)), - self.fader_bottom - 4, - fill="yellow", - state="hidden", - tags=(f"strip:{self.fader_bg})")) + for col in range(4): + self.pedals.append( + self.canvas.create_rectangle( + int(x + self.width / 5 * col), + self.gui_mixer.legend_y + self.legend_height - 4, + int(x + self.width / 5 * (col + 1)), + self.gui_mixer.legend_y + self.legend_height, + width=0, + fill="yellow", + state=tkinter.HIDDEN + ) ) + self.midi_indicator = self.canvas.create_rectangle( + int(x + self.width / 5 * 4), + self.gui_mixer.legend_y + self.legend_height - 4, + int(x + self.width), + self.gui_mixer.legend_y + self.legend_height, + width=0, + fill=zynthian_gui_config.color_status_midi, + state=tkinter.HIDDEN + ) + + # Clip Launcher Progress Bar + self.clip_progress = self.canvas.create_rectangle(x, self.gui_mixer.legend_y, x, self.gui_mixer.legend_y + 4, width=0, fill=self.gui_mixer.legend_txt_color, tags=(f"legend_strip_{id}",)) + + # Indicators + self.record_indicator = self.canvas.create_text(x + 2, self.gui_mixer.legend_y + self.gui_mixer.legend_height - 16, text="⚫", fill="#009000", anchor="sw", state=tkinter.HIDDEN) + self.play_indicator = self.canvas.create_text(x + 2, self.gui_mixer.legend_y + self.gui_mixer.legend_height - 2, text="⏹", fill="#009000", anchor="sw", state=tkinter.HIDDEN) + + # Bind events to gui elements + self.canvas.tag_bind(f"fader_{id}", "", self.on_fader_press) + self.canvas.tag_bind(f"fader_{id}", "", self.on_fader_release) + self.canvas.tag_bind(f"fader_{id}", "", self.on_fader_motion) + self.canvas.tag_bind(f"fader_{id}", "", self.on_fader_wheel_up) + self.canvas.tag_bind(f"fader_{id}", "", self.on_fader_wheel_down) + if self.chain.zynmixer_proc: + self.canvas.tag_bind(self.fader_horizontal, "", self.on_fader_wheel_up) + self.canvas.tag_bind(self.fader_horizontal, "", self.on_fader_wheel_down) + self.canvas.tag_bind(f"balance_{id}", "", self.on_balance_wheel_up) + self.canvas.tag_bind(f"balance_{id}", "", self.on_balance_wheel_down) + self.canvas.tag_bind(f"mute_{id}", "", self.on_mute_release) + self.canvas.tag_bind(f"solo_{id}", "", self.on_solo_release) + self.canvas.tag_bind(f"legend_strip_{id}", "", self.on_strip_release) - # Balance indicator - self.balance_left = self.parent.main_canvas.create_rectangle(x, self.balance_top, self.fader_centre_x, self.balance_top + self.balance_height, - fill=self.left_color, width=0, tags=(f"strip:{self.fader_bg}", f"balance:{self.fader_bg}", f"audio_strip:{self.fader_bg}")) - self.balance_right = self.parent.main_canvas.create_rectangle(self.fader_centre_x + 1, self.balance_top, self.width, self.balance_top + - self.balance_height, fill=self.right_color, width=0, tags=(f"strip:{self.fader_bg}", f"balance:{self.fader_bg}", f"audio_strip:{self.fader_bg}")) - self.balance_text = self.parent.main_canvas.create_text(self.fader_centre_x, int( - self.balance_top + self.balance_height / 2) - 1, text="??", font=self.font_learn, state=tkinter.HIDDEN) - self.parent.main_canvas.tag_bind( - f"balance:{self.fader_bg}", "", self.on_balance_press) - - # Fader indicators - self.record_indicator = self.parent.main_canvas.create_text( - x + 2, self.height - 16, text="⚫", fill="#009000", anchor="sw", tags=(f"strip:{self.fader_bg}"), state=tkinter.HIDDEN) - self.play_indicator = self.parent.main_canvas.create_text( - x + 2, self.height - 2, text="⏹", fill="#009000", anchor="sw", tags=(f"strip:{self.fader_bg}"), state=tkinter.HIDDEN) - - self.parent.zyngui.multitouch.tag_bind(self.parent.main_canvas, "fader:%s" % ( - self.fader_bg), "press", self.on_fader_press) - self.parent.zyngui.multitouch.tag_bind(self.parent.main_canvas, "fader:%s" % ( - self.fader_bg), "motion", self.on_fader_motion) - self.parent.zyngui.multitouch.tag_bind(self.parent.main_canvas, "fader:%s" % ( - self.fader_bg), "motion", self.on_fader_motion) - self.parent.main_canvas.tag_bind( - f"fader:{self.fader_bg}", "", self.on_fader_press) - self.parent.main_canvas.tag_bind( - f"fader:{self.fader_bg}", "", self.on_fader_release) - self.parent.main_canvas.tag_bind( - f"fader:{self.fader_bg}", "", self.on_fader_motion) - if zynthian_gui_config.force_enable_cursor: - self.parent.main_canvas.tag_bind( - f"fader:{self.fader_bg}", "", self.on_fader_wheel_up) - self.parent.main_canvas.tag_bind( - f"fader:{self.fader_bg}", "", self.on_fader_wheel_down) - self.parent.main_canvas.tag_bind( - f"balance:{self.fader_bg}", "", self.on_balance_wheel_up) - self.parent.main_canvas.tag_bind( - f"balance:{self.fader_bg}", "", self.on_balance_wheel_down) - self.parent.main_canvas.tag_bind( - f"legend_strip:{self.fader_bg}", "", self.parent.on_wheel) - self.parent.main_canvas.tag_bind( - f"legend_strip:{self.fader_bg}", "", self.parent.on_wheel) - self.parent.main_canvas.tag_bind( - f"mute:{self.fader_bg}", "", self.on_mute_release) - self.parent.main_canvas.tag_bind( - f"solo_button:{self.fader_bg}", "", self.on_solo_release) - self.parent.main_canvas.tag_bind( - f"legend_strip:{self.fader_bg}", "", self.on_strip_press) - self.parent.main_canvas.tag_bind( - f"legend_strip:{self.fader_bg}", "", self.on_strip_release) - self.parent.main_canvas.tag_bind( - f"legend_strip:{self.fader_bg}", "", self.on_strip_motion) - - self.draw_control() - - def hide(self): - """ Function to hide mixer strip - """ - self.parent.main_canvas.itemconfig( - f"strip:{self.fader_bg}", state=tkinter.HIDDEN) - self.hidden = True - - def show(self): - """ Function to show mixer strip - """ - self.dpm_a.set_strip(self.chain.mixer_chan) - self.dpm_b.set_strip(self.chain.mixer_chan) - self.parent.main_canvas.itemconfig(f"strip:{self.fader_bg}", state=tkinter.NORMAL) - try: - if not self.chain.is_audio(): - self.parent.main_canvas.itemconfig(f"audio_strip:{self.fader_bg}", state=tkinter.HIDDEN) - except: - pass - self.hidden = False self.draw_control() - def get_ctrl_learn_text(self, ctrl): - if not self.chain.is_audio(): - return "" + def set_launcher_mode(self, mode): try: - param = self.zynmixer.get_learned_cc(self.zctrls[ctrl]) - return f"{param[0] + 1}#{param[1]}" + if mode: + self.canvas.coords(self.dpm_bg, self.dpm_a_x0, 0, self.x + self.width, self.balance_y) + self.dpm_a.move(self.dpm_a_x0, 0, self.dpm_width, self.balance_y) + self.dpm_b.move(self.dpm_b_x0, 0, self.dpm_width, self.balance_y) + #self.canvas.coords(self.solo, self.x, self.solo_y, self.dpm_a_x0, self.mute_y) + #self.canvas.coords(self.mute, self.x, self.mute_y, self.dpm_a_x0, self.balance_y) + else: + self.canvas.coords(self.dpm_bg, self.dpm_a_x0, self.dpm_y0, self.x + self.width, self.dpm_y0 + self.dpm_length) + self.dpm_a.move(self.dpm_a_x0, self.dpm_y0, self.dpm_width, self.dpm_length) + self.dpm_b.move(self.dpm_b_x0, self.dpm_y0, self.dpm_width, self.dpm_length) + #self.canvas.coords(self.solo, self.x, self.solo_y, self.x + self.width, self.mute_y) + #self.canvas.coords(self.mute, self.x, self.mute_y, self.x + self.width, self.balance_y) except: - return "??" + pass # meters not yet created? - def draw_dpm(self, state): + def draw_dpm(self): """ Function to draw the DPM level meter for a mixer strip - state = [dpm_a, dpm_b, hold_a, hold_b, mono] """ - if self.hidden or self.chain.mixer_chan is None: - return - self.dpm_a.refresh(state[0], state[2], state[4]) - self.dpm_b.refresh(state[1], state[3], state[4]) + + dpm = self.chain.zynmixer_proc.zynmixer.dpm[self.chain.zynmixer_proc.mixer_chan] + self.dpm_a.refresh(dpm.a, dpm.a_hold, dpm.mono) + self.dpm_b.refresh(dpm.b, dpm.b_hold, dpm.mono) def draw_balance(self): - balance = self.zynmixer.get_balance(self.chain.mixer_chan) + """ + Draws the mixer strip balance indication + """ + + balance = self.chain.zynmixer_proc.controllers_dict["balance"].value if balance is None: return if balance > 0: - self.parent.main_canvas.coords(self.balance_left, - self.x + balance * self.width / 2, self.balance_top, - self.x + self.width / 2, self.balance_top + self.balance_height) - self.parent.main_canvas.coords(self.balance_right, - self.x + self.width / 2, self.balance_top, - self.x + self.width, self.balance_top + self.balance_height) - else: - self.parent.main_canvas.coords(self.balance_left, - self.x, self.balance_top, - self.x + self.width / 2, self.balance_top + self. balance_height) - self.parent.main_canvas.coords(self.balance_right, - self.x + self.width / 2, self.balance_top, - self.x + self.width * balance / 2 + self.width, self.balance_top + self.balance_height) - - if self.parent.zynmixer.midi_learn_zctrl == self.zctrls["balance"]: - lcolor = self.learn_color_hl - rcolor = self.learn_color - txcolor = zynthian_gui_config.color_ml - txstate = tkinter.NORMAL - text = "??" - elif self.parent.zynmixer.midi_learn_zctrl: - lcolor = self.learn_color_hl - rcolor = self.learn_color - txcolor = zynthian_gui_config.color_hl - txstate = tkinter.NORMAL - text = f"{self.get_ctrl_learn_text('balance')}" + x = self.centre_x + balance * self.balance_width + 1 else: - lcolor = self.left_color - rcolor = self.right_color - txcolor = self.button_txcol - txstate = tkinter.HIDDEN - text = "" - - self.parent.main_canvas.itemconfig(self.balance_left, fill=lcolor) - self.parent.main_canvas.itemconfig(self.balance_right, fill=rcolor) - self.parent.main_canvas.itemconfig( - self.balance_text, state=txstate, text=text, fill=txcolor) + x = self.centre_x + balance * self.balance_width - 1 + self.canvas.coords(self.balance_fg, + self.centre_x, self.balance_y, + x, self.balance_y + self.balance_height) + """Draws the mixer strip level""" def draw_level(self): - level = self.zynmixer.get_level(self.chain.mixer_chan) + level = self.chain.zynmixer_proc.controllers_dict["level"].value if level is not None: - self.parent.main_canvas.coords(self.fader, self.x, self.fader_top + self.fader_height * ( - 1 - level), self.x + self.fader_width, self.fader_bottom) - - def draw_fader(self): - if self.zctrls and self.parent.zynmixer.midi_learn_zctrl == self.zctrls["level"]: - self.parent.main_canvas.coords( - self.fader_text, self.fader_centre_x, self.fader_centre_y - 2) - self.parent.main_canvas.itemconfig(self.fader_text, text="??", font=self.font_learn, angle=0, - fill=zynthian_gui_config.color_ml, justify=tkinter.CENTER, anchor=tkinter.CENTER) - elif self.parent.zynmixer.midi_learn_zctrl: - text = self.get_ctrl_learn_text('level') - self.parent.main_canvas.coords( - self.fader_text, self.fader_centre_x, self.fader_centre_y - 2) - self.parent.main_canvas.itemconfig(self.fader_text, text=text, font=self.font_learn, angle=0, - fill=zynthian_gui_config.color_hl, justify=tkinter.CENTER, anchor=tkinter.CENTER) - else: - if self.chain is not None: - label_parts = self.chain.get_description( - 2).split("\n") + [""] # TODO - else: - label_parts = ["No info"] - - for i, label in enumerate(label_parts): - self.parent.main_canvas.itemconfig(self.fader_text, text=label, state=tkinter.NORMAL) - bounds = self.parent.main_canvas.bbox(self.fader_text) - if bounds[1] < self.fader_text_limit: - while bounds and bounds[1] < self.fader_text_limit: - label = label[:-1] - self.parent.main_canvas.itemconfig( - self.fader_text, text=label) - bounds = self.parent.main_canvas.bbox(self.fader_text) - label_parts[i] = label + "..." - self.parent.main_canvas.itemconfig(self.fader_text, text="\n".join( - label_parts), font=self.font_fader, angle=90, fill=self.legend_txt_color, justify=tkinter.LEFT, anchor=tkinter.NW) - self.parent.main_canvas.coords( - self.fader_text, self.x, self.fader_bottom - 2) + self.canvas.coords(self.fader_overlay, + self.x, self.fader_y + self.gui_mixer.fader_height * (1 - level), + self.x + self.fader_width, self.legend_y) + self.canvas.coords(self.fader_horizontal, + self.x, self.fader_y, + self.x + self.width * level, self.fader_y + self.balance_height) + + def draw_fader_text(self): + label_parts = self.chain.get_description(2).split("\n") + [""] + for i, label in enumerate(label_parts): + self.canvas.itemconfig(self.fader_text, text=label) + bounds = self.canvas.bbox(self.fader_text) + if bounds and bounds[3] - bounds[1] > self.fader_text_limit: + while bounds and bounds[3] - bounds[1] > self.fader_text_limit: + label = label[:-1] + self.canvas.itemconfig(self.fader_text, text=label) + bounds = self.canvas.bbox(self.fader_text) + label_parts[i] = label + "..." + self.canvas.itemconfig(self.fader_text, text="\n".join(label_parts)) + + def update_clip_progress(self, progress): + x1 = self.x + int(progress * self.width / 100) + self.canvas.coords(self.clip_progress, self.x, self.gui_mixer.legend_y, x1, self.gui_mixer.legend_y + 4) def draw_solo(self): - txcolor = self.button_txcol - font = self.font + txcolor = self.gui_mixer.button_txcol + font = self.gui_mixer.font text = "S" - if self.zynmixer.get_solo(self.chain.mixer_chan): - if self.parent.zynmixer.midi_learn_zctrl: - bgcolor = self.learn_color_hl - else: - bgcolor = self.solo_color + if self.chain.zynmixer_proc.eng_code == "MR" and self.chain.chain_id == 0: + # Main mixbus so use the global solo state + solo = self.state_manager.zynmixer_bus.get_global_solo() > 0 else: - if self.parent.zynmixer.midi_learn_zctrl: - bgcolor = self.learn_color - else: - bgcolor = self.button_bgcol - - if self.parent.zynmixer.midi_learn_zctrl == self.zctrls["solo"]: - txcolor = zynthian_gui_config.color_ml - elif self.parent.zynmixer.midi_learn_zctrl: - txcolor = zynthian_gui_config.color_hl - font = self.font_learn - text = f"S {self.get_ctrl_learn_text('solo')}" + solo = self.chain.zynmixer_proc.controllers_dict["solo"].value + if solo: + bgcolor = self.gui_mixer.solo_color + else: + bgcolor = self.gui_mixer.button_bgcol - self.parent.main_canvas.itemconfig(self.solo, fill=bgcolor) - self.parent.main_canvas.itemconfig( - self.solo_text, text=text, font=font, fill=txcolor) + self.canvas.itemconfig(self.solo, fill=bgcolor) + self.canvas.itemconfig(self.solo_text, text=text, font=font, fill=txcolor) def draw_mute(self): - txcolor = self.button_txcol - font = self.font_icons - if self.zynmixer.get_mute(self.chain.mixer_chan): - if self.parent.zynmixer.midi_learn_zctrl: - bgcolor = self.learn_color_hl - else: - bgcolor = self.mute_color + txcolor = self.gui_mixer.button_txcol + font = self.gui_mixer.font_icons + if self.chain.zynmixer_proc.controllers_dict["mute"].value: + bgcolor = self.gui_mixer.mute_color text = "\uf32f" else: - if self.parent.zynmixer.midi_learn_zctrl: - bgcolor = self.learn_color - else: - bgcolor = self.button_bgcol + bgcolor = self.gui_mixer.button_bgcol text = "\uf028" - if self.parent.zynmixer.midi_learn_zctrl == self.zctrls["mute"]: - txcolor = zynthian_gui_config.color_ml - elif self.parent.zynmixer.midi_learn_zctrl: - txcolor = zynthian_gui_config.color_hl - font = self.font_learn - text = f"\uf32f {self.get_ctrl_learn_text('mute')}" - - self.parent.main_canvas.itemconfig(self.mute, fill=bgcolor) - self.parent.main_canvas.itemconfig( - self.mute_text, text=text, font=font, fill=txcolor) - - def draw_mono(self): - """ - if self.zynmixer.get_mono(self.chain.mixer_chan): - self.parent.main_canvas.itemconfig(self.dpm_l_a, fill=self.mono_color) - self.parent.main_canvas.itemconfig(self.dpm_l_b, fill=self.mono_color) - self.dpm_hold_color = "#FFFFFF" - else: - self.parent.main_canvas.itemconfig(self.dpm_l_a, fill=self.low_color) - self.parent.main_canvas.itemconfig(self.dpm_l_b, fill=self.low_color) - self.dpm_hold_color = "#00FF00" - """ + self.canvas.itemconfig(self.mute, fill=bgcolor) + self.canvas.itemconfig(self.mute_text, text=text, font=font, fill=txcolor) def draw_control(self, control=None): - """ Function to draw a mixer strip UI control - control: Name of control or None to redraw all controls in the strip """ - if self.hidden or self.chain is None: # or self.zctrls is None: - return + Function to draw a mixer strip UI control + Args: + control: Name of control or None to redraw all controls in the strip + """ - if control == None: - if self.chain_id == 0: - self.parent.main_canvas.itemconfig( - self.legend_strip_txt, text="Main", font=self.font) + if control is None: + # Draw the common elements used by all strips + if self.chain.chain_id == 0: + self.canvas.itemconfig(self.legend_strip_txt, text="Main", font=self.gui_mixer.font) else: - font = self.font - if self.parent.moving_chain and self.chain_id == self.parent.zyngui.chain_manager.active_chain_id: - strip_txt = f"⇦⇨" - elif isinstance(self.chain.midi_chan, int): + if self.chain.is_generator(): + font = self.gui_mixer.font_icons + strip_txt = "\uf028" # Speaker icon + elif self.chain.is_midi(): + font = self.gui_mixer.font + if self.chain.audio_thru: + strip_txt = "\uf130♫" # Add microphone icon for MIDI+Audio chains + else: + strip_txt = "♫ " if 0 <= self.chain.midi_chan < 16: - strip_txt = f"♫ {self.chain.midi_chan + 1}" + strip_txt += f"{self.chain.midi_chan + 1}" elif self.chain.midi_chan == 0xffff: - strip_txt = f"♫ All" + strip_txt += f"All" else: - strip_txt = f"♫ Err" - elif self.chain.is_audio: - strip_txt = "\uf130" - font = self.font_icons + strip_txt += f"Err" + elif self.chain.is_audio(): + font = self.gui_mixer.font_icons + if self.chain.zynmixer_proc.eng_code == "MI": + strip_txt = "\uf130" # Microphone icon + else: + strip_txt = "\uf1de" # Sliders else: - strip_txt = "\uf0ae" - font = self.font_icons + font = self.gui_mixer.font_icons + strip_txt = "" # procs = self.chain.get_processor_count() - 1 - self.parent.main_canvas.itemconfig( - self.legend_strip_txt, text=strip_txt, font=font) - self.draw_fader() - try: - if not self.chain.is_audio(): - self.parent.main_canvas.itemconfig( - self.record_indicator, state=tkinter.HIDDEN) - self.parent.main_canvas.itemconfig( - self.play_indicator, state=tkinter.HIDDEN) - return - except Exception as e: - logging.error(e) + self.canvas.itemconfig(self.legend_strip_txt, text=strip_txt, font=font) + self.draw_fader_text() - if self.zctrls: + if self.chain.zynmixer_proc: if control in [None, 'level']: self.draw_level() @@ -485,124 +678,72 @@ def draw_control(self, control=None): if control in [None, 'balance']: self.draw_balance() - if control in [None, 'mono']: - self.draw_mono() - - if control in [None, 'rec']: - if self.chain.is_audio() and self.parent.zyngui.state_manager.audio_recorder.is_armed(self.chain.mixer_chan): - if self.parent.zyngui.state_manager.audio_recorder.status: - self.parent.main_canvas.itemconfig( - self.record_indicator, fill=self.rec_color, state=tkinter.NORMAL) - else: - self.parent.main_canvas.itemconfig( - self.record_indicator, fill=self.high_color, state=tkinter.NORMAL) - else: - self.parent.main_canvas.itemconfig( - self.record_indicator, state=tkinter.HIDDEN) - - if control in [None, 'play']: - try: - processor = self.chain.synth_slots[0][0] - if processor.eng_code == "AP": - if zynaudioplayer.get_playback_state(processor.handle): - self.parent.main_canvas.itemconfig( - self.play_indicator, text="▶", fill="#009000", state=tkinter.NORMAL) + if control in [None, 'record']: + if self.chain.zynmixer_proc.controllers_dict['record'].value: + if self.state_manager.audio_recorder.status: + self.canvas.itemconfig( + self.record_indicator, fill=self.gui_mixer.rec_color, state=tkinter.NORMAL) else: - self.parent.main_canvas.itemconfig( - self.play_indicator, text="⏹", fill="#909090", state=tkinter.NORMAL) + self.canvas.itemconfig( + self.record_indicator, fill=self.gui_mixer.high_color, state=tkinter.NORMAL) else: - self.parent.main_canvas.itemconfig( - self.play_indicator, state=tkinter.HIDDEN) - except: - self.parent.main_canvas.itemconfig( - self.play_indicator, state=tkinter.HIDDEN) + self.canvas.itemconfig( + self.record_indicator, state=tkinter.HIDDEN) + + if control in [None, 'play']: + try: + processor = self.chain.synth_slots[0][0] + if processor.eng_code == "AP": + if zynaudioplayer.get_playback_state(processor.handle): + self.canvas.itemconfig(self.play_indicator, text="▶", fill="#009000", state=tkinter.NORMAL) + else: + self.canvas.itemconfig(self.play_indicator, text="⏹", fill="#909090", state=tkinter.NORMAL) + else: + self.canvas.itemconfig(self.play_indicator, state=tkinter.HIDDEN) + except: + self.canvas.itemconfig(self.play_indicator, state=tkinter.HIDDEN) # -------------------------------------------------------------------------- # Mixer Strip functionality # -------------------------------------------------------------------------- - def set_highlight(self, hl=True): - """ Function to highlight/downlight the strip - hl: Boolean => True=highlight, False=downlight - """ - if hl: - self.set_fader_color(self.fader_bg_color_hl) - self.parent.main_canvas.itemconfig( - self.legend_strip_bg, fill=self.legend_bg_color_hl) - else: - self.set_fader_color(self.fader_color) - self.parent.main_canvas.itemconfig( - self.legend_strip_bg, fill=self.fader_bg_color) - - def set_fader_color(self, fg, bg=None): - """ Function to set fader colors - fg: Fader foreground color - bg: Fader background color (optional - Default: Do not change background color) - """ - self.parent.main_canvas.itemconfig(self.fader, fill=fg) - if bg: - self.parent.main_canvas.itemconfig(self.fader_bg_color, fill=bg) - - def set_chain(self, chain_id): - """ Function to set chain associated with mixer strip - chain: Chain object - """ - self.chain_id = chain_id - self.chain = self.parent.zyngui.chain_manager.get_chain(chain_id) - if self.chain is None: - self.hide() - self.dpm_a.set_strip(None) - self.dpm_b.set_strip(None) - else: - if self.chain.mixer_chan is not None and self.chain.mixer_chan < len(self.parent.zynmixer.zctrls): - self.zctrls = self.parent.zynmixer.zctrls[self.chain.mixer_chan] - self.show() - def set_volume(self, value): """ Function to set volume value value: Volume value (0..1) """ - if self.parent.zynmixer.midi_learn_zctrl: - self.parent.enter_midi_learn(self.zctrls["level"]) - elif self.zctrls: - self.zctrls['level'].set_value(value) + if self.chain.zynmixer_proc: + self.chain.zynmixer_proc.controllers_dict['level'].set_value(value) def get_volume(self): """ Function to get volume value """ - if self.zctrls: - return self.zctrls['level'].value + if self.chain.zynmixer_proc: + return self.chain.zynmixer_proc.controllers_dict['level'].value def nudge_volume(self, dval): """ Function to nudge volume """ - if self.parent.zynmixer.midi_learn_zctrl: - self.parent.enter_midi_learn(self.zctrls["level"]) - elif self.zctrls: - self.zctrls["level"].nudge(dval) + if self.chain.zynmixer_proc: + self.chain.zynmixer_proc.controllers_dict["level"].nudge(dval) def set_balance(self, value): """ Function to set balance value value: Balance value (-1..1) """ - if self.parent.zynmixer.midi_learn_zctrl: - self.parent.enter_midi_learn(self.zctrls["balance"]) - elif self.zctrls: - self.zctrls["balance"].set_value(value) + if self.chain.zynmixer_proc: + self.chain.zynmixer_proc.controllers_dict["balance"].set_value(value) + def get_balance(self): """ Function to get balance value """ - if self.zctrls: - return self.zctrls['balance'].value + if self.chain.zynmixer_proc: + return self.chain.zynmixer_proc.controllers_dict['balance'].value def nudge_balance(self, dval): """ Function to nudge balance """ - if self.parent.zynmixer.midi_learn_zctrl: - self.parent.enter_midi_learn(self.zctrls["balance"]) - self.parent.refresh_visible_strips() - elif self.zctrls: - self.zctrls['balance'].nudge(dval) + if self.chain.zynmixer_proc: + self.chain.zynmixer_proc.controllers_dict['balance'].nudge(dval) def reset_volume(self): """ Function to reset volume @@ -617,189 +758,120 @@ def set_mute(self, value): """ Function to set mute value: Mute value (True/False) """ - if self.parent.zynmixer.midi_learn_zctrl: - self.parent.enter_midi_learn(self.zctrls["mute"]) - elif self.zctrls: - self.zctrls['mute'].set_value(value) - # self.parent.refresh_visible_strips() + if self.chain.zynmixer_proc: + self.chain.zynmixer_proc.controllers_dict['mute'].set_value(value) def set_solo(self, value): """ Function to set solo value: Solo value (True/False) """ - if self.parent.zynmixer.midi_learn_zctrl: - self.parent.enter_midi_learn(self.zctrls["solo"]) - elif self.zctrls: - self.zctrls['solo'].set_value(value) - if self.chain_id == 0: - self.parent.refresh_visible_strips() - - def set_mono(self, value): - """ Function to toggle mono - value: Mono value (True/False) - """ - if self.parent.zynmixer.midi_learn_zctrl: - self.parent.enter_midi_learn(self.zctrls["mono"]) - elif self.zctrls: - self.zctrls['mono'].set_value(value) - self.parent.refresh_visible_strips() + if self.chain.zynmixer_proc: + self.chain.zynmixer_proc.controllers_dict['solo'].set_value(value) def toggle_mute(self): """ Function to toggle mute """ - if self.zctrls: - self.set_mute(int(not self.zctrls['mute'].value)) + if self.chain.zynmixer_proc: + self.set_mute(int(not self.chain.zynmixer_proc.controllers_dict['mute'].value)) def toggle_solo(self): """ Function to toggle solo """ - if self.zctrls: - self.set_solo(int(not self.zctrls['solo'].value)) - - def toggle_mono(self): - """ Function to toggle mono - """ - if self.zctrls: - self.set_mono(int(not self.zctrls['mono'].value)) + if self.chain.zynmixer_proc: + self.set_solo(int(not self.chain.zynmixer_proc.controllers_dict['solo'].value)) # -------------------------------------------------------------------------- - # UI event management + # Mixer UI event management # -------------------------------------------------------------------------- def on_fader_press(self, event): """ Function to handle fader press - event: Mouse event + Args: + event: Mouse event """ - self.touch_y = event.y - self.touch_x = event.x - self.drag_axis = None # +1=dragging in y-axis, -1=dragging in x-axis - self.touch_ts = monotonic() - if zynthian_gui_config.zyngui.cb_touch(event): - return "break" - self.fader_drag_start = event - if self.chain: - self.parent.zyngui.chain_manager.set_active_chain_by_object( - self.chain) - self.parent.highlight_active_chain() + self.dragging = False + if self.zyngui.cb_touch(event): + return "break" + if self.chain.is_audio(): + self.fader_start_value = self.chain.zynmixer_proc.controllers_dict['level'].value + self.fader_press_event = event + self.chain_manager.set_active_chain_by_object(self.chain) # Function to handle fader press # event: Mouse event def on_fader_release(self, event): - self.touch_ts = None + self.fader_press_event = None def on_fader_motion(self, event): """ Function to handle fader drag - event: Mouse event + Args: + event: Mouse event """ - if self.touch_ts: - dts = monotonic() - self.touch_ts - if dts < 0.1: # debounce initial touch + if not self.fader_press_event or not self.chain.is_audio(): return - dy = self.touch_y - event.y - dx = event.x - self.touch_x - - # Lock drag to x or y axis only after one has been started - if self.drag_axis is None: + if event.time - self.fader_press_event.time < 100: # debounce initial touch + return + dy = event.y - self.fader_press_event.y + if not self.dragging: if abs(dy) > 2: - self.drag_axis = "y" - elif abs(dx) > 2: - self.drag_axis = "x" - - if self.drag_axis == "y": - self.set_volume( - self.zctrls['level'].value + (self.touch_y - event.y) / self.fader_height) - self.touch_y = event.y - elif self.drag_axis == "x": - self.set_balance( - self.zctrls['balance'].value - (self.touch_x - event.x) / self.fader_width) - self.touch_x = event.x + self.dragging = True + if self.dragging: + self.set_volume(self.fader_start_value + (self.fader_press_event.y - event.y) / self.gui_mixer.fader_height) # Function to handle mouse wheel down over fader # event: Mouse event def on_fader_wheel_down(self, event): - self.nudge_volume(-1) + if not event.state: + self.nudge_volume(-1) def on_fader_wheel_up(self, event): """ Function to handle mouse wheel up over fader - event: Mouse event + Args: + event: Mouse event """ - self.nudge_volume(1) - def on_balance_press(self, event): - """ Function to handle mouse click / touch of balance - event: Mouse event - """ - if self.parent.zynmixer.midi_learn_zctrl: - if self.parent.zynmixer.midi_learn_zctrl != self.zctrls["balance"]: - self.parent.zynmixer.midi_learn_zctrl = self.zctrls["balance"] + if not event.state: + self.nudge_volume(1) def on_balance_wheel_down(self, event): """ Function to handle mouse wheel down over balance - event: Mouse event + Args: + event: Mouse event """ - self.nudge_balance(-1) + + if not event.state: + self.nudge_balance(-1) def on_balance_wheel_up(self, event): """ Function to handle mouse wheel up over balance - event: Mouse event + Args: + event: Mouse event """ - self.nudge_balance(1) - def on_strip_press(self, event): - """ Function to handle mixer strip press - event: Mouse event - """ - if zynthian_gui_config.zyngui.cb_touch(event): - return "break" - - self.strip_drag_start = event - self.dragging = False - if self.chain: - self.parent.zyngui.chain_manager.set_active_chain_by_object( - self.chain) + if not event.state: + self.nudge_balance(1) def on_strip_release(self, event): """ Function to handle legend strip release + Args: + event: Mouse event """ - if zynthian_gui_config.zyngui.cb_touch_release(event): - return "break" - - if self.parent.zynmixer.midi_learn_zctrl: + if self.zyngui.cb_touch_release(event): + return "break" #TODO: "break" does not work with tab binding! + if not self.gui_mixer.press_event or self.gui_mixer.dragging: return - if self.strip_drag_start and not self.dragging: - delta = event.time - self.strip_drag_start.time - if delta > 400: - zynthian_gui_config.zyngui.screens['chain_options'].setup( - self.chain_id) - zynthian_gui_config.zyngui.show_screen('chain_options') - else: - zynthian_gui_config.zyngui.chain_control(self.chain_id) - self.dragging = False - self.strip_drag_start = None - self.parent.end_moving_chain() - def on_strip_motion(self, event): - """ Function to handle legend strip drag - """ - if self.strip_drag_start: - delta = event.x - self.strip_drag_start.x - if delta > self.width: - offset = +1 - elif delta < -self.width: - offset = -1 + self.chain_manager.set_active_chain_by_id(self.chain.chain_id) + if not self.gui_mixer.dragging: + delta = event.time - self.gui_mixer.press_event.time + self.gui_mixer.press_event = None + if delta > 400: + self.zyngui.screens['chain_manager'].select_chain_options_node() + self.zyngui.show_screen('chain_manager') else: - return - # Dragged more than one strip width - self.dragging = True - if self.parent.moving_chain: - self.parent.zyngui.chain_manager.move_chain(offset) - elif self.parent.mixer_strip_offset - offset >= 0 and self.parent.mixer_strip_offset - offset + len(self.parent.visible_mixer_strips) <= len(self.parent.zyngui.chain_manager.chains): - self.parent.mixer_strip_offset -= offset - self.strip_drag_start.x = event.x - self.parent.refresh_visible_strips() - self.parent.highlight_active_chain() + self.zyngui.chain_control(self.chain.chain_id) def on_mute_release(self, event): """ Function to handle mute button release @@ -813,86 +885,263 @@ def on_solo_release(self, event): """ self.toggle_solo() + # ------------------------------------------------------------------------------ # Zynthian Mixer GUI Class # ------------------------------------------------------------------------------ - -class zynthian_gui_mixer(zynthian_gui_base.zynthian_gui_base): +class zynthian_gui_mixer(zynthian_gui_base): def __init__(self): super().__init__(has_backbutton=False) - self.zynmixer = self.zyngui.state_manager.zynmixer - self.zynmixer.set_midi_learn_cb(self.enter_midi_learn) - self.MAIN_MIXBUS_STRIP_INDEX = self.zynmixer.MAX_NUM_CHANNELS - 1 - self.chan2strip = [None] * (self.MAIN_MIXBUS_STRIP_INDEX + 1) - self.highlighted_strip = None # highligted mixer strip object - self.moving_chain = False # True if moving a chain left/right + self.main_frame.columnconfigure(0, weight=1) + self.main_frame.columnconfigure(1, weight=0) + self.main_frame.rowconfigure(0, weight=1) + self.left_canvas = tkinter.Canvas( + self.main_frame, + bd=0, + highlightthickness=0, + bg=zynthian_gui_config.color_panel_bg + ) + self.left_canvas.grid(row=0, column=0, sticky="news") + self.right_canvas = tkinter.Canvas( + self.main_frame, + bd=0, + highlightthickness=0, + bg=zynthian_gui_config.color_panel_bg + ) + self.right_canvas.grid(row=0, column=1, sticky="nes", padx=(4,0)) + + self.ctrl_order = zynthian_gui_config.layout['ctrl_order'] # List of encoder indices + + self.state_manager = self.zyngui.state_manager + self.chain_manager = self.zyngui.chain_manager + self.zynseq = self.state_manager.zynseq + self.bpb = 4 + self.beat = 0 + self.chain_strips = [] # List of channel strips excluding main mixbus, indexed by strip position + self.state_changed = True + self.press_event = None + self.dragging = False # True if click/touch dragging + self._scroll_gen = 0 # Identifier for current scroll job - avoid concurrent thread conflicts + self._scroll_y = 0 # Current vertial scroll offset in pixels + self.scrollable_strips = 0 # Quantity of strips in left, scrollable canvas + self._top_phrase = 0 # Index of phrase currently displayed at top of view + self._left_chain = 0 # Index of chain currently displayed at left of view + + self.alt_mode = False + self.launcher_mode = self.zyngui.alt_mode + + self.chan2strip = {} # Map of audio strips, indexed by [is_mixbus, mixer_channel] + self.highlighted_strip = None # Highligted mixer strip object + self.moving_phrase = False # True if moving a launcher phrase up/down # List of (strip,control) requiring gui refresh (control=None for whole strip refresh) self.pending_refresh_queue = set() - # TODO: Should avoid duplicating midi_learn_zctrl from zynmixer but would need more safeguards to make change. - self.midi_learn_sticky = None - # Maximum quantity of mixer strips to display (Defines strip width. Main always displayed.) - visible_chains = zynthian_gui_config.visible_mixer_strips - if visible_chains < 1: + self.status_tempo = self.status_canvas.create_text( + int(self.status_l - self.status_fs * 3.5), 2, + anchor=tkinter.NE, + fill=zynthian_gui_config.color_header_tx, + font=("forkawesome", int(0.25 * self.status_h)), + text="120.0 bpm", + state=tkinter.NORMAL) + + self.status_timesig = self.status_canvas.create_text( + int(self.status_l - self.status_fs * 8.5), 2, + anchor=tkinter.NE, + fill=zynthian_gui_config.color_header_tx, + font=("forkawesome", int(0.25 * self.status_h)), + text="1 | 4/4", + state=tkinter.NORMAL) + + self.left_canvas.bind("", self.on_press) + self.left_canvas.bind("", self.on_motion) + self.left_canvas.bind("", self.on_release) + self.left_canvas.bind("", self.on_wheel) + self.left_canvas.bind("", self.on_wheel) + self.right_canvas.bind("", self.on_press) + self.right_canvas.bind("", self.on_motion) + self.right_canvas.bind("", self.on_release) + self.right_canvas.bind("", self.on_wheel) + self.right_canvas.bind("", self.on_wheel) + + self.update_layout() + + def cb_rename_chain(self, chain_id, title): + for strip in self.chain_strips: + if strip.chain.chain_id == chain_id: + strip.draw_fader_text() + return + + def cb_state_change(self): + # Flag for deferred update to throttle expensive screen updates + self.state_changed = True + + def update_layout(self): + """Function to update display, e.g. after geometry or chain changes + """ + + self.state_changed = True + super().update_layout() + + # Update geometry + if zynthian_gui_config.visible_mixer_strips < 1: # Automatic sizing if not defined in config if self.width <= 400: - visible_chains = 6 + self.visible_chains = 6 elif self.width <= 600: - visible_chains = 8 + self.visible_chains = 8 elif self.width <= 800: - visible_chains = 10 + self.visible_chains = 10 elif self.width <= 1024: - visible_chains = 12 + self.visible_chains = 12 elif self.width <= 1280: - visible_chains = 14 + self.visible_chains = 14 else: - visible_chains = 16 - - self.fader_width = (self.width - 6) / (visible_chains + 1) - self.legend_height = self.height * 0.05 - self.edit_height = self.height * 0.1 - - self.fader_height = self.height - self.edit_height - self.legend_height - 2 - self.fader_bottom = self.height - self.legend_height - self.fader_top = self.fader_bottom - self.fader_height - self.balance_control_height = self.fader_height * 0.1 - self.balance_top = self.fader_top - # Width of each half of balance control - self.balance_control_width = self.width / 4 - self.balance_control_centre = self.fader_width + self.balance_control_width - - # Arrays of GUI elements for mixer strips - Chains + Main - # List of mixer strip objects indexed by horizontal position on screen - self.visible_mixer_strips = [None] * visible_chains - self.mixer_strip_offset = 0 # Index of first mixer strip displayed on far left - - # Fader Canvas - self.main_canvas = tkinter.Canvas(self.main_frame, - height=1, - width=1, - bd=0, highlightthickness=0, - bg=zynthian_gui_config.color_panel_bg) - self.main_frame.rowconfigure(0, weight=1) - self.main_frame.columnconfigure(0, weight=1) - self.main_canvas.grid(row=0, sticky='nsew') + self.visible_chains = 16 + else: + self.visible_chains = zynthian_gui_config.visible_mixer_strips - # Create mixer strip UI objects - for strip in range(len(self.visible_mixer_strips)): - self.visible_mixer_strips[strip] = zynthian_gui_mixer_strip( - self, 1 + self.fader_width * strip, 0, self.fader_width - 1, self.height) + self.strip_width = self.width / (self.visible_chains + 0.2) + self.button_height = int(self.height * 0.07) + self.legend_height = int(self.height * 0.08) + self.balance_height = int(self.height * 0.03) + self.solo_y = 0 + self.mute_y = self.solo_y + self.button_height + self.balance_y = self.mute_y + self.button_height + self.fader_y = self.balance_y + self.balance_height + self.launcher_y = self.fader_y + self.balance_height + self.legend_y = self.height - self.legend_height + self.fader_height = self.legend_y - self.fader_y + + # Style + self.fader_bg_color = zynthian_gui_config.color_panel_bg + self.fader_color = zynthian_gui_config.color_off + self.fader_color_hl = "#6a727d" # "#207024" + self.legend_txt_color = zynthian_gui_config.color_tx + self.legend_bg_color = zynthian_gui_config.color_panel_bg + self.legend_bg_color_hl = zynthian_gui_config.color_on + self.main_legend_bg_color = "#550000" + self.bus_legend_bg_color = "#000055" + self.button_bgcol = zynthian_gui_config.color_panel_bg + self.button_txcol = zynthian_gui_config.color_tx + self.balance_bg_color = "#888888" + self.balance_fg_color = "#00EE00" + self.high_color = "#CCCCCC" # yellow + self.rec_color = "#CC0000" # red + self.mute_color = zynthian_gui_config.color_on # "#3090F0" + self.solo_color = "#D0D000" + self.mono_color = "#B0B0B0" + font_size = min(int(0.5 * self.legend_height), int(0.25 * self.width)) + self.font = (zynthian_gui_config.font_family, font_size) + self.font_fader = (zynthian_gui_config.font_family, int(0.9 * font_size)) + self.font_clip_state = (zynthian_gui_config.font_family, int(0.6 * font_size)) + self.font_clip_title = (zynthian_gui_config.font_family, int(0.7 * font_size)) + self.font_timebase = (zynthian_gui_config.font_family, int(0.5 * font_size)) + self.font_icons = ("forkawesome", int(1.2 * font_size)) - self.main_mixbus_strip = zynthian_gui_mixer_strip( - self, self.width - self.fader_width - 1, 0, self.fader_width - 1, self.height) - self.main_mixbus_strip.set_chain(0) - self.main_mixbus_strip.zctrls = self.zynmixer.zctrls[self.MAIN_MIXBUS_STRIP_INDEX] + if zynthian_gui_config.visible_launchers < 1: + # Automatic sizing if not defined in config + if self.fader_height <= 400: + visible_launchers = 4 + elif self.width <= 600: + visible_launchers = 6 + elif self.width <= 800: + visible_launchers = 8 + elif self.width <= 1024: + visible_launchers = 10 + elif self.width <= 1280: + visible_launchers = 12 + else: + visible_launchers = 14 + else: + visible_launchers = zynthian_gui_config.visible_launchers + + self.launcher_height = int((self.legend_y - self.launcher_y) / (visible_launchers + 0.2)) - self.zynmixer.enable_dpm(0, self.MAIN_MIXBUS_STRIP_INDEX, False) + #self.load_mode_icons() + self.build_mixer() - self.refresh_visible_strips() + # Clip Mode Icons + def load_mode_icons(self): + empty_icon = tkinter.PhotoImage() + iconsize = (int(self.strip_width * 0.4), int(self.launcher_height * 0.30)) + self.mode_icons = {} + for f in ("empty", "loopsync", "oneshot", "oneshotall"): + try: + img = Image.open(f"/zynthian/zynthian-ui/icons/zynpad_mode_{f}.png") + self.mode_icons[f] = ImageTk.PhotoImage(img.resize(iconsize)) + except: + self.mode_icons[f] = empty_icon + + def build_mixer(self): + """ Draw chain strips""" + + self.state_changed = True + + # Create mixer strip UI objects + self.chan2strip = {} + self.chain_strips = [] + self.left_canvas.delete("all") + self.right_canvas.delete("all") + self.right_canvas.configure(width=self.strip_width * self.chain_manager.get_pinned_count()) + self.scrollable_strips = len(self.chain_manager.chains) - self.chain_manager.get_pinned_count() + div = self.chain_manager.get_pinned_pos() + x0 = 0 + canvas = self.left_canvas + for idx, chain in enumerate(list(self.chain_manager.chains.values())): + if idx == div: + x0 = 0 + canvas = self.right_canvas + # Create the strip object + strip = zynthian_gui_mixer_strip(self, canvas, x0, self.strip_width, self.height, chain) + x0 += self.strip_width + self.chain_strips.append(strip) + # Add to optimisation map + if chain.zynmixer_proc: + self.chan2strip[chain.zynmixer_proc.eng_code=="MR", chain.zynmixer_proc.mixer_chan] = self.chain_strips[idx] + + self.build_launchers() + self.state_changed = False + self.left_canvas.configure(scrollregion=(0, 0, self.chain_manager.get_pinned_pos() * self.strip_width, self.height)) + self.refresh_launchers() + self.refresh_mixer_controls() + self.set_launcher_mode() + + def build_launchers(self): + """ Build the sequence launcher buttons """ + self.left_canvas.delete("launcher") + self.right_canvas.delete("launcher") + self.launcher_total_height = self.launcher_height * self.zynseq.phrases + + canvas = self.left_canvas + div = self.chain_manager.get_pinned_pos() + for col, strip in enumerate(self.chain_strips): + if col == div: + canvas = self.right_canvas + strip.launchers = [] + y = self.launcher_y - self._scroll_y + for idx in range(self.zynseq.phrases): + strip.launchers.append(zynthian_gui_launcher_pad(self, canvas, strip.x, y, self.strip_width, self.launcher_height, strip.chain, idx)) + y += self.launcher_height + self.refresh_launchers() + + def refresh_launchers(self): + if self.state_changed: + return # Avoid refreshing controls whilst rebuilding state + if not self.launcher_mode: + return + for strip in self.chain_strips: + for launcher in strip.launchers: + launcher.draw() + self.highlight_launcher() + + def refresh_mixer_controls(self): + for strip in self.chain_strips: + strip.draw_control() + self.highlight_chain(self.chain_manager.active_chain.chain_id) def init_dpmeter(self): self.dpm_a = self.dpm_b = None @@ -900,110 +1149,144 @@ def init_dpmeter(self): def set_title(self, title="", fg=None, bg=None, timeout=None): """ Redefine set_title """ - if title == "" and self.zyngui.state_manager.last_snapshot_fpath: - fparts = os.path.splitext(self.zyngui.state_manager.last_snapshot_fpath) + if title == "" and self.state_manager.last_snapshot_fpath: + fparts = splitext(self.state_manager.last_snapshot_fpath) if self.zyngui.screens['snapshot'].bankless_mode: - ssname = os.path.basename(fparts[0]) + ssname = basename(fparts[0]) else: ssname = fparts[0].rsplit("/", 1)[-1] title = ssname.replace("last_state", "Last State") - zs3_name = self.zyngui.state_manager.get_zs3_title() + zs3_name = self.state_manager.get_zs3_title() if zs3_name and zs3_name != "Last state": title += f": {zs3_name}" super().set_title(title, fg, bg, timeout) - def hide(self): - """ Function to handle hiding display - """ - if self.shown: - if not self.zyngui.osc_clients: - self.zynmixer.enable_dpm( - 0, self.MAIN_MIXBUS_STRIP_INDEX - 1, False) - if not self.midi_learn_sticky: - self.exit_midi_learn() - zynsigman.unregister( - zynsigman.S_AUDIO_MIXER, self.zynmixer.SS_ZCTRL_SET_VALUE, self.update_control) - zynsigman.unregister( - zynsigman.S_STATE_MAN, self.zyngui.state_manager.SS_LOAD_ZS3, self.cb_load_zs3) - zynsigman.unregister( - zynsigman.S_CHAIN_MAN, self.zyngui.chain_manager.SS_SET_ACTIVE_CHAIN, self.update_active_chain) - zynsigman.unregister( - zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_ARM, self.update_control_arm) - zynsigman.unregister( - zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_STATE, self.update_control_rec) - zynsigman.unregister( - zynsigman.S_AUDIO_PLAYER, zynthian_engine_audioplayer.SS_AUDIO_PLAYER_STATE, self.update_control_play) - zynsigman.unregister( - zynsigman.S_MIDI, zynsigman.SS_MIDI_CC, self.midi_cc_cb) - zynsigman.unregister( - zynsigman.S_MIDI, zynsigman.SS_MIDI_PC, self.midi_pc_cb) - zynsigman.unregister( - zynsigman.S_STATE_MAN, self.zyngui.state_manager.SS_ALL_NOTES_OFF, self.cb_all_notes_off) - super().hide() - def build_view(self): - """ Function to handle showing display - """ - self.refresh_visible_strips() - if zynthian_gui_config.enable_touch_navigation and self.moving_chain or self.zynmixer.midi_learn_zctrl: + """ Function to handle showing display""" + #try: + # self.build_mixer() #TODO: Don't do full rebuild + #except Exception as e: + # logging.warning(e) + #self.set_launcher_mode() + + self.build_mixer() + if zynthian_gui_config.enable_touch_navigation and self.moving_phrase: self.show_back_button() - self.set_title() if zynthian_gui_config.enable_dpm: - self.zynmixer.enable_dpm(0, self.MAIN_MIXBUS_STRIP_INDEX, True) + self.state_manager.zynmixer_chan.enable_dpm(True) + self.state_manager.zynmixer_bus.enable_dpm(True) + self.left_canvas.itemconfig("dpm", state=tkinter.NORMAL) + self.right_canvas.itemconfig("dpm", state=tkinter.NORMAL) else: - # Reset all DPM which will not be updated by refresh - for strip in self.visible_mixer_strips: - strip.draw_dpm([-200, -200, -200, -200, False]) + # Hide DPMs + self.left_canvas.itemconfig("dpm", state=tkinter.HIDDEN) + self.right_canvas.itemconfig("dpm", state=tkinter.HIDDEN) - self.highlight_active_chain(True) self.setup_zynpots() - if self.midi_learn_sticky: - self.enter_midi_learn(self.midi_learn_sticky) - else: - zynsigman.register( - zynsigman.S_AUDIO_MIXER, self.zynmixer.SS_ZCTRL_SET_VALUE, self.update_control) - zynsigman.register_queued( - zynsigman.S_STATE_MAN, self.zyngui.state_manager.SS_LOAD_ZS3, self.cb_load_zs3) - zynsigman.register_queued( - zynsigman.S_CHAIN_MAN, self.zyngui.chain_manager.SS_SET_ACTIVE_CHAIN, self.update_active_chain) - zynsigman.register_queued( - zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_ARM, self.update_control_arm) - zynsigman.register_queued( - zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_STATE, self.update_control_rec) - zynsigman.register_queued( - zynsigman.S_AUDIO_PLAYER, zynthian_engine_audioplayer.SS_AUDIO_PLAYER_STATE, self.update_control_play) - zynsigman.register_queued( - zynsigman.S_MIDI, zynsigman.SS_MIDI_CC, self.midi_cc_cb) - zynsigman.register_queued( - zynsigman.S_MIDI, zynsigman.SS_MIDI_PC, self.midi_pc_cb) - zynsigman.register_queued( - zynsigman.S_STATE_MAN, self.zyngui.state_manager.SS_ALL_NOTES_OFF, self.cb_all_notes_off) + + if not self.shown: + self.set_tempo() + zynsigman.register(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, self.update_control) + zynsigman.register_queued(zynsigman.S_MIDI, zynsigman.SS_MIDI_CC, self.midi_cc_cb) + zynsigman.register_queued(zynsigman.S_MIDI, zynsigman.SS_MIDI_PC, self.midi_pc_cb) + zynsigman.register_queued(zynsigman.S_STATE_MAN, self.state_manager.SS_LOAD_ZS3, self.load_zs3_cb) + zynsigman.register_queued(zynsigman.S_STATE_MAN, self.state_manager.SS_ALL_NOTES_OFF, self.all_notes_off_cb) + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.update_active_chain) + zynsigman.register_queued(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_RENAME_CHAIN, self.cb_rename_chain) + zynsigman.register_queued(zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_STATE, self.update_control_rec) + zynsigman.register_queued(zynsigman.S_AUDIO_RECORDER, self.state_manager.audio_recorder.SS_AUDIO_RECORDER_ARM, self.audio_recorder_arm_cb) + zynsigman.register_queued(zynsigman.S_AUDIO_PLAYER, zynthian_engine_audioplayer.SS_AUDIO_PLAYER_STATE, self.update_control_play) + zynsigman.register_queued(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_SELECT_PHRASE, self.highlight_launcher) + zynsigman.register_queued(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_TEMPO, self.set_tempo) + zynsigman.register_queued(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_TIMESIG, self.set_bpb) + zynsigman.register_queued(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_PLAY_STATE, self.launcher_play_state_cb) + zynsigman.register_queued(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_STATE, self.refresh_launchers) + return True - def update_layout(self): - """Function to update display, e.g. after geometry changes + def hide(self): + """ Function to handle hiding display """ - super().update_layout() - # TODO: Update mixer layout + if self.shown: + if not self.zyngui.osc_clients: + self.zyngui.state_manager.zynmixer_chan.enable_dpm(False) + self.zyngui.state_manager.zynmixer_bus.enable_dpm(False) + zynsigman.unregister(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, self.update_control) + zynsigman.unregister(zynsigman.S_MIDI, zynsigman.SS_MIDI_CC, self.midi_cc_cb) + zynsigman.unregister(zynsigman.S_MIDI, zynsigman.SS_MIDI_PC, self.midi_pc_cb) + zynsigman.unregister(zynsigman.S_STATE_MAN, self.state_manager.SS_LOAD_ZS3, self.load_zs3_cb) + zynsigman.unregister(zynsigman.S_STATE_MAN, self.state_manager.SS_ALL_NOTES_OFF, self.all_notes_off_cb) + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_SET_ACTIVE_CHAIN, self.update_active_chain) + zynsigman.unregister(zynsigman.S_CHAIN_MAN, self.chain_manager.SS_RENAME_CHAIN, self.cb_rename_chain) + zynsigman.unregister(zynsigman.S_AUDIO_RECORDER, zynthian_audio_recorder.SS_AUDIO_RECORDER_STATE, self.update_control_rec) + zynsigman.unregister(zynsigman.S_AUDIO_RECORDER, self.state_manager.audio_recorder.SS_AUDIO_RECORDER_ARM, self.audio_recorder_arm_cb) + zynsigman.unregister(zynsigman.S_AUDIO_PLAYER, zynthian_engine_audioplayer.SS_AUDIO_PLAYER_STATE, self.update_control_play) + zynsigman.unregister(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_SELECT_PHRASE, self.highlight_launcher) + zynsigman.unregister(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_TEMPO, self.set_tempo) + zynsigman.unregister(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_TIMESIG, self.set_bpb) + zynsigman.unregister(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_PLAY_STATE, self.launcher_play_state_cb) + zynsigman.unregister(zynsigman.S_STEPSEQ, zynseq.SS_SEQ_STATE, self.refresh_launchers) + super().hide() + + def set_tempo(self, tempo=None): + if tempo is None: + self.status_canvas.itemconfig(self.status_tempo, text=f"{self.zynseq.get_tempo():.1f} bpm") + else: + self.status_canvas.itemconfig(self.status_tempo, fill=zynthian_gui_config.color_ml, text=f"{tempo:.1f} bpm") + Timer(0.6, self.clear_tempo_highlight).start() + + def clear_tempo_highlight(self): + self.status_canvas.itemconfig(self.status_tempo, fill=zynthian_gui_config.color_header_tx) + + def set_bpb(self, bpb): + self.bpb = bpb + self.status_canvas.itemconfig(self.status_timesig, fill=zynthian_gui_config.color_ml, text=f"{self.beat} | {bpb}/4") + Timer(0.6, self.clear_timesig_highlight).start() + + def clear_timesig_highlight(self): + self.status_canvas.itemconfig(self.status_timesig, fill=zynthian_gui_config.color_header_tx) def refresh_status(self): """Function to refresh screen (slow) """ + if self.shown: super().refresh_status() - # Update main chain DPM - state = self.zynmixer.get_dpm_states(255, 255)[0] - self.main_mixbus_strip.draw_dpm(state) - # Update other chains DPM if zynthian_gui_config.enable_dpm: - states = self.zynmixer.get_dpm_states( - 0, self.MAIN_MIXBUS_STRIP_INDEX - 1) - for strip in self.visible_mixer_strips: - if not strip.hidden and strip.chain.mixer_chan is not None: - state = states[strip.chain.mixer_chan] - strip.draw_dpm(state) + # Update all chains DPM + self.zyngui.state_manager.zynmixer_chan.update_dpm_states() + self.zyngui.state_manager.zynmixer_bus.update_dpm_states() + if zynthian_gui_config.enable_dpm: + for strip in self.chain_strips: + if strip.chain.is_audio(): + strip.draw_dpm() + else: + # Update main chain DPM + self.state_manager.zynmixer_bus.update_dpm_states(1) + self.chain_strips[-1].draw_dpm() + if self.beat != self.zynseq.beat: + self.beat = self.zynseq.beat + self.status_canvas.itemconfig(self.status_timesig, text=f"{self.beat} | {self.bpb}/4") + for strip in self.chain_strips: + # Update MIDI activity indicators + if strip.chain.midi_chan is not None: + if strip.chain.midi_chan < 16: + midi_act = self.zyngui.state_manager.status_midi_ch & (1 << strip.chain.midi_chan) + elif strip.chain.midi_chan > 32: + midi_act = self.zyngui.state_manager.status_midi_ch != 0 + else: + midi_act = False + if midi_act: + strip.canvas.itemconfig(strip.midi_indicator, state=tkinter.NORMAL) + else: + strip.canvas.itemconfig(strip.midi_indicator, state=tkinter.HIDDEN) + # Update progress indicators + if strip.chain.midi_chan is not None and strip.chain.midi_chan < 32: + strip.update_clip_progress(self.zynseq.progress[strip.chain.midi_chan]) + elif strip.chain.chain_id == 0: + strip.update_clip_progress(self.zynseq.progress[32]) def plot_zctrls(self): """Function to refresh display (fast) @@ -1013,44 +1296,58 @@ def plot_zctrls(self): if ctrl[0]: ctrl[0].draw_control(ctrl[1]) - def update_control(self, chan, symbol, value): + def update_control(self, mixbus, chan, symbol, value): """Mixer control update signal handler + chan: Mixer channel + symbol: Mixer control symbol + value: Control value """ - strip = self.chan2strip[chan] - if not strip or not strip.chain or strip.chain.mixer_chan is None: + + try: + strip = self.chan2strip[(mixbus, chan)] + except: + strip = None + if not strip or not strip.chain or strip.chain.zynmixer_proc.mixer_chan is None: return - self.pending_refresh_queue.add((strip, symbol)) + if symbol == "solo" and strip.chain.chain_id == 0: + for s in self.chain_strips: + self.pending_refresh_queue.add((s, symbol)) + else: + self.pending_refresh_queue.add((strip, symbol)) if symbol == "level": - value = strip.zctrls["level"].value + #value = strip.zctrls["level"].value if value > 0: level_db = 20 * log10(value) self.set_title(f"Volume: {level_db:.2f}dB ({strip.chain.get_description(1)})", None, None, 1) else: self.set_title(f"Volume: -∞dB ({strip.chain.get_description(1)})", None, None, 1) elif symbol == "balance": - strip.parent.set_title(f"Balance: {int(value * 100)}% ({strip.chain.get_description(1)})", None, None, 1) - - def update_control_arm(self, chan, value): - """Function to handle audio recorder arm - """ - self.update_control(chan, "rec", value) + #strip.gui_mixer.set_title(f"Balance: {int(value * 100)}% ({strip.chain.get_description(1)})", None, None, 1) + strip.gui_mixer.set_title(f"Balance: {int(value * 100):+}% ({strip.chain.get_name()})", None, None, 1) def update_control_rec(self, state): """ Function to handle audio recorder status """ - for strip in self.visible_mixer_strips: - self.pending_refresh_queue.add((strip, "rec")) + for strip in self.chain_strips: + self.pending_refresh_queue.add((strip, "record")) def update_control_play(self, handle, state): """ Function to handle audio play status """ - for strip in self.visible_mixer_strips: + for strip in self.chain_strips: self.pending_refresh_queue.add((strip, "play")) - def update_active_chain(self, active_chain): - """ Funtion to handle active chain changes + def update_active_chain(self, active_chain_id, send=False): + """ Function to handle active chain changes + Args: + chain_id: Active chain id + send: True to set chain manager active chain """ - self.highlight_active_chain() + + if send: + self.chain_manager.set_active_chain_by_id(active_chain_id) + self.highlight_chain(active_chain_id) + self.select_launcher() for cc in (64, 66, 67, 69): self.midi_cc_cb(0, 0, cc, 0) @@ -1061,103 +1358,575 @@ def midi_cc_cb(self, izmip, chan, num, val): return try: flags = lib_zyncore.get_cc_pedal(index) - for strip in self.visible_mixer_strips: + for strip in self.chain_strips: if strip.chain and strip.chain.is_midi(): if flags & (1 << strip.chain.zmop_index): - self.main_canvas.itemconfig(strip.pedals[index], state=tkinter.NORMAL) + strip.canvas.itemconfigure(strip.pedals[index], state=tkinter.NORMAL) else: - self.main_canvas.itemconfig(strip.pedals[index], state=tkinter.HIDDEN) + strip.canvas.itemconfig(strip.pedals[index], state=tkinter.HIDDEN) except Exception as e: logging.warning(e) def midi_pc_cb(self, izmip, chan, num): - if zynthian_gui_config.midi_prog_change_zs3: + if zynthian_gui_config.midi_prog_change_zs3 or self.launcher_mode: return - for strip in self.visible_mixer_strips: + for strip in self.chain_strips: if strip.chain and strip.chain.midi_chan == chan: - strip.draw_fader() + strip.draw_fader_text() - def cb_load_zs3(self, zs3_id): - self.refresh_visible_strips() + def load_zs3_cb(self, zs3_id): + self.refresh_mixer_controls() self.set_title() - def cb_all_notes_off(self, chan=None): - for strip in self.visible_mixer_strips: + def all_notes_off_cb(self, chan=None): + for strip in self.chain_strips: if strip.chain and strip.chain.is_midi() and (chan is None or strip.chain.midi_chan == chan): for i in range(0, 4): - self.main_canvas.itemconfig(strip.pedals[i], state=tkinter.HIDDEN) + strip.canvas.itemconfig(strip.pedals[i], state=tkinter.HIDDEN) + + def highlight_launcher(self, phrase=None): + if not self.launcher_mode: + return + if phrase is None: + phrase = self.zynseq.phrase + self.left_canvas.itemconfig("launcher_pad", outline="") + self.right_canvas.itemconfig("launcher_pad", outline="") + try: + self.highlighted_strip.launchers[phrase].highlight() + except: + pass + + # Scroll to ensure launcher is visible - use coords relative to launcher view + launcher_top = phrase * self.launcher_height + launcher_bottom = launcher_top + self.launcher_height + view_top = self._scroll_y + view_bottom = view_top + self.fader_height + + if launcher_top < view_top: + # Scroll up + new_y = launcher_top - 0.15 * self.launcher_height + elif launcher_bottom > view_bottom: + # Scroll down + new_y = launcher_bottom + 0.15 * self.launcher_height - self.legend_y + self.launcher_y + else: + return # already fully visible + self.scroll_canvas(None, new_y, self.shown) + + def audio_recorder_arm_cb(self, channel, mixbus, value): + pos = self.chain_manager.get_pos_by_mixer_chan(channel, mixbus) + try: + self.chain_strips[pos].draw_control("record") + except: + pass + + def launcher_play_state_cb(self, phrase, chan): + if not self.launcher_mode: + return + if chan == 32: + self.chain_strips[-1].launchers[phrase].draw() + else: + for strip in self.chain_strips: + if strip.chain.midi_chan == chan: + strip.launchers[phrase].draw() + + def topbar_bold_touch_action(self): + self.toggle_launcher_mode() + + def toggle_menu(self): + if self.shown: + # Chain options selected + self.zyngui.screens['chain_manager'].select_chain_options_node() + self.zyngui.toggle_screen("chain_manager") + elif self.zyngui.get_current_screen() == "option": + self.zyngui.close_screen() + + def item_menu(self): + if self.launcher_mode and self.zynseq.phrase < self.zynseq.phrases: + # Launcher Options + self.phrase_menu() + else: + # Current processor selected + self.zyngui.screens['chain_manager'].select_node(proc=self.chain_manager.active_chain.current_processor) + self.zyngui.show_screen('chain_manager') # -------------------------------------------------------------------------- - # Mixer Functionality + # Selection and scrolling # -------------------------------------------------------------------------- - # Function to highlight the selected chain's strip - def highlight_active_chain(self, refresh=False): - """ Higlights active chain, redrawing strips if required + def highlight_chain(self, chain_id): + """ Highlights chain, redrawing strips if required """ + + if not self.chain_strips: + return + try: + active_index = self.chain_manager.get_chain_index(chain_id) + self.highlighted_strip = self.chain_strips[active_index] + self.left_canvas.itemconfig("legend", fill=self.fader_bg_color) + self.right_canvas.itemconfig("legend", fill=self.fader_bg_color) + self.left_canvas.itemconfig("fader_overlay", fill=self.fader_color) + self.right_canvas.itemconfig("fader_overlay", fill=self.fader_color) + except: + active_index = len(self.chain_strips) - 1 + self.highlighted_strip = self.chain_strips[active_index] + + self.left_canvas.itemconfig("legend", fill=self.fader_bg_color) + self.right_canvas.itemconfig("legend", fill=self.fader_bg_color) + self.right_canvas.itemconfig("legend_strip_main", fill=self.main_legend_bg_color) + self.left_canvas.itemconfig("legend_strip_bus", fill=self.bus_legend_bg_color) + self.right_canvas.itemconfig("legend_strip_bus", fill=self.bus_legend_bg_color) + self.highlighted_strip.canvas.itemconfig(self.highlighted_strip.legend_strip_bg, fill=self.legend_bg_color_hl) + self.left_canvas.itemconfig("fader_overlay", fill=self.fader_color) + self.right_canvas.itemconfig("fader_overlay", fill=self.fader_color) + if self.highlighted_strip.chain.is_audio(): + self.highlighted_strip.canvas.itemconfig(self.highlighted_strip.fader_overlay, fill=self.fader_color_hl) + self.highlight_launcher() + + # Scroll to ensure chain is visible + if active_index >= self.chain_manager.get_pinned_pos(): + return + strip_left = active_index * self.strip_width + strip_right = strip_left + self.strip_width + canvas_width = self.left_canvas.winfo_width() + view_left = self.left_canvas.canvasx(0) + view_right = view_left + canvas_width try: - active_index = self.zyngui.chain_manager.ordered_chain_ids.index( - self.zyngui.chain_manager.active_chain_id) + content_width = self.left_canvas.bbox("all")[2] except: - active_index = 0 - if active_index < self.mixer_strip_offset: - self.mixer_strip_offset = active_index - refresh = True - elif active_index >= self.mixer_strip_offset + len(self.visible_mixer_strips) and self.zyngui.chain_manager.active_chain_id != 0: - self.mixer_strip_offset = active_index - len(self.visible_mixer_strips) + 1 - refresh = True - # TODO: Handle aux - - strip = None - if self.zyngui.chain_manager.active_chain_id == 0: - strip = self.main_mixbus_strip + return # No content yet + + if content_width <= canvas_width: + # Nothing to scroll + return + if strip_left < view_left: + # Scroll left + new_x = strip_left - 0.3 * self.strip_width + elif strip_right > view_right: + # Scroll right + new_x = strip_right - canvas_width + 0.3 * self.strip_width + else: + return # already fully visible + self.scroll_canvas(new_x / content_width, None, self.shown) + + def scroll_canvas(self, target_x=None, target_y=None, smooth=True): + """ Scroll the view + Args: + target_x: Target x-axis offset ratio (None to ignore) + target_y: Target y-axis offset absolute (None to ignore) + smooth: True to scroll smoothly + Note: Horizontal scrolling is done with view move for smooth DPM behaviour. Vertical scrolling is done by moving the launcher pads. + TODO: Should we separate these? We use the same callback here so slight optimisation in combining. + """ + + self._scroll_gen += 1 + gen = self._scroll_gen + dx = dy = 0 + steps = 30 + delay = 10 + + def step(i=0): + if gen != self._scroll_gen: + return # new scroll job started superceeding this job + if i >= steps: + # Ensure exact final position + send_sig = False + if target_x is not None: + self.left_canvas.xview_moveto(target_x) + left_chain = min(self.scrollable_strips - self.visible_chains, max(0, int(target_x * self.scrollable_strips + 0.4))) + if self._left_chain != left_chain: + self._left_chain = left_chain + send_sig = True + if target_y is not None: + dy0 = self._scroll_y - target_y + self.left_canvas.move("launcher", 0, dy0) + self.right_canvas.move("launcher", 0, dy0) + self._scroll_y = target_y + # Calculate top left chain/phrase + top_phrase = int(target_y / self.launcher_height + 0.4) + if self._top_phrase != top_phrase: + self._top_phrase = top_phrase + send_sig = True + if send_sig: + zynsigman.send(zynsigman.S_GUI, zynsigman.SS_GUI_VIEW_POS, left_chain=self._left_chain, top_phrase=self._top_phrase) + return + if target_x is not None: + self.left_canvas.xview_moveto(start_x + dx * (i + 1)) + if target_y is not None: + self.left_canvas.move("launcher", 0, dy) + self.right_canvas.move("launcher", 0, dy) + self._scroll_y -= dy + self.right_canvas.after(delay, step, i + 1) + + if target_x is not None: + try: + start_x = self.left_canvas.xview()[0] + except: + return + target_x = max(0.0, min(target_x, 1.0)) + dx = (target_x - start_x) / steps + if target_y is not None: + # Reverse direction to move objects, not view + target_y = max(-3, min(target_y, self.launcher_total_height - self.fader_height + 12)) + dy = (self._scroll_y - target_y) / steps + if smooth: + step() + else: + step(steps) + + def on_press(self, event): + self.press_event = event + self.start_xview = event.widget.xview()[0] + self.start_yview = event.widget.yview()[0] + self.dragging = False + + def on_motion(self, event): + if not self.press_event: + return + # Check threshold + dx = self.press_event.x - event.x + dy = self.press_event.y - event.y + if not self.dragging: + if self.launcher_mode and self.press_event.widget == self.right_canvas and abs(dy) > DRAG_THRESHOLD: + self.dragging = True + elif self.press_event.widget == self.left_canvas and self.press_event.y > self.legend_y and abs(dx) > DRAG_THRESHOLD: + self.dragging = True + else: + return + try: + sr = event.widget.bbox("all") + # Horizontal Move + if self.press_event.widget == self.left_canvas: + sr_w = sr[2] - sr[0] + canvas_w = event.widget.winfo_width() + if sr_w > canvas_w: + d_fract_x = dx / float(sr_w) + xview = self.start_xview + d_fract_x + self.scroll_canvas(target_x=xview, smooth=False) + # Vertical Move + elif self.press_event.widget == self.right_canvas: + if not self.launcher_mode: + return + if self.moving_phrase: + #TODO: Improve view edge handling + dP = int(dy / self.launcher_height) + if dP > 0: + self.arrow_up() + self.press_event.y = event.y + elif dP < 0: + self.arrow_down() + self.press_event.y = event.y + else: + self.scroll_canvas(0, self._scroll_y + dy, False) + self.press_event.y = event.y + except Exception as e: + pass + + def on_release(self, event): + self.press_event = None + self.dragging = False + + def on_wheel(self, event): + """ Handle mouse wheel event + Args: + event: Mouse event + Note: Use modifier key to alter behaviour + """ + + if event.y < self.launcher_y: + return + if event.num == 4: + if event.state or event.y > self.legend_y: + self.arrow_right() + elif self.launcher_mode: + self.arrow_up() else: - chain = self.zyngui.chain_manager.get_chain( - self.zyngui.chain_manager.active_chain_id) - for s in self.visible_mixer_strips: - if s.chain == chain: - strip = s - break - if strip is None: - refresh = True - if refresh: - chan_strip = self.refresh_visible_strips() - if chan_strip: - strip = chan_strip - if self.highlighted_strip and self.highlighted_strip != strip: - self.highlighted_strip.set_highlight(False) - if strip is None: - strip = self.main_mixbus_strip - self.highlighted_strip = strip - if strip: - strip.set_highlight(True) - - # Function refresh and populate visible mixer strips - def refresh_visible_strips(self): - """ Update the structures describing the visible strips - - returns - Active strip object + if event.state or event.y > self.legend_y: + self.arrow_left() + elif self.launcher_mode: + self.arrow_down() + + # -------------------------------------------------------------------------- + # Launcher Functionality + # -------------------------------------------------------------------------- + + def set_launcher_mode(self, launcher_mode=None): + if launcher_mode is None: + launcher_mode = self.launcher_mode + if not self.chain_strips: + self.launcher_mode = False + else: + self.launcher_mode = launcher_mode + + for strip in self.chain_strips: + strip.set_launcher_mode(launcher_mode) + + if self.launcher_mode: + self.refresh_launchers() + self.left_canvas.itemconfig("fader", state=tkinter.HIDDEN) + self.right_canvas.itemconfig("fader", state=tkinter.HIDDEN) + self.left_canvas.itemconfig("fader_horizontal", state=tkinter.NORMAL) + self.right_canvas.itemconfig("fader_horizontal", state=tkinter.NORMAL) + self.left_canvas.tag_lower("launcher") + self.right_canvas.tag_lower("launcher") + self.left_canvas.itemconfig("launcher", state=tkinter.NORMAL) + self.right_canvas.itemconfig("launcher", state=tkinter.NORMAL) + self.highlight_launcher() + else: + self.left_canvas.itemconfig("fader", state=tkinter.NORMAL) + self.right_canvas.itemconfig("fader", state=tkinter.NORMAL) + self.left_canvas.itemconfig("fader_horizontal", state=tkinter.HIDDEN) + self.right_canvas.itemconfig("fader_horizontal", state=tkinter.HIDDEN) + self.left_canvas.itemconfig("launcher", state=tkinter.HIDDEN) + self.right_canvas.itemconfig("launcher", state=tkinter.HIDDEN) + zynsigman.send(zynsigman.S_GUI, zynsigman.SS_GUI_LAUNCHER_MODE, mode=launcher_mode) + + def toggle_launcher_mode(self): + self.set_launcher_mode(not self.launcher_mode) + + def phrase_menu(self): + try: + if self.highlighted_strip.chan == zynseq.PHRASE_CHANNEL: + info = self.zynseq.state["scenes"][self.zynseq.scene]["phrases"][self.zynseq.phrase] + else: + info = self.zynseq.state["scenes"][self.zynseq.scene]["phrases"][self.zynseq.phrase]["sequences"][self.highlighted_strip.chan] + except Exception as e: + info = None + if not info: + return + options = {} + phrase = self.zynseq.phrase + name = info["name"] + repeat = info["repeat"] + follow_action = info["followAction"] + follow_phrase = phrase + info["followParam"] + title = f"Phrase options" + if name: + title += f": {name}" + #options["> Phrase Options"] = None + if repeat == 0: + options["Duration (DISABLED)"] = repeat + else: + if repeat == 255: + options["Duration (AUTO)"] = repeat + else: + if repeat == 1: + unit = "bar" + else: + unit = "bars" + options[f"Duration ({repeat} {unit})"] = repeat + if follow_action == zynseq.FOLLOW_ACTION_NONE: + options[f"Follow action (NONE)"] = 0 + elif follow_action == zynseq.FOLLOW_ACTION_RELATIVE: + offset, follow_name = self.get_follow_info(follow_phrase) + options[f"Follow action ({follow_name})"] = offset + if 'tempo' not in info or info['tempo'] == 0.0: + options[f"Tempo (NONE)"] = False + else: + options[f"Tempo ({info['tempo']:.1f})"] = info['tempo'] + options["Remove tempo"] = self.zynseq.phrase + if "bpb" not in info or not info["bpb"]: + options[f"Beats per bar (NONE)"] = 0 + else: + options[f"Beats per bar ({info['bpb']})"] = info["bpb"] + if name: + options[f"Rename ({name})"] = name + else: + options[f"Rename"] = "" + options["> EDIT"] = None + options["Insert phrase"] = phrase + options["Clone phrase"] = phrase + if self.zynseq.phrases > 1: + options["Move phrase"] = phrase + options["Delete phrase"] = phrase + + self.zyngui.screens['option'].config(title, options, self.phrase_menu_cb, close_on_select=False) + self.zyngui.show_screen('option') + + def get_follow_info(self, phrase): + """ Get the offset and text representing the follow action + Args: + phrase: Index of phrase + Returns: Tuple (offset, title) """ - active_strip = None - strip_index = 0 - for chain_id in self.zyngui.chain_manager.ordered_chain_ids[:-1][self.mixer_strip_offset:self.mixer_strip_offset + len(self.visible_mixer_strips)]: - strip = self.visible_mixer_strips[strip_index] - strip.set_chain(chain_id) - # strip.draw_control() - if strip.chain.mixer_chan is not None and strip.chain.mixer_chan < len(self.chan2strip): - self.chan2strip[strip.chain.mixer_chan] = strip - if chain_id == self.zyngui.chain_manager.active_chain_id: - active_strip = strip - strip_index += 1 - - # Hide unpopulated strips - for strip in self.visible_mixer_strips[strip_index:len(self.visible_mixer_strips)]: - strip.set_chain(None) - strip.zctrls = None - - self.chan2strip[self.MAIN_MIXBUS_STRIP_INDEX] = self.main_mixbus_strip - self.main_mixbus_strip.draw_control() - return active_strip + + offset = phrase - self.zynseq.phrase + title = self.zynseq.state["scenes"][self.zynseq.scene]["phrases"][phrase]["name"] + if not title: + title = chr(ord('A') + phrase) + if offset == -1: + title = f"PREV ({title})" + elif offset == 0: + title = "NONE" + elif offset == 1: + title = f"NEXT ({title})" + elif offset > 0: + title = f"+{offset} ({title})" + else: + title = f"{offset} ({title})" + return (offset, title) + + def phrase_menu_cb(self, option, params): + option_screen = self.zyngui.screens["option"] + if option.startswith("Rename"): + self.zyngui.show_keyboard(self.rename_phrase, params, 8) + elif option.startswith("Append phrase"): + self.zynseq.insert_phrase(self.zynseq.scene, self.zynseq.phrases) + self.build_launchers() + self.zyngui.show_screen("launcher") + elif option.startswith("Insert phrase"): + self.zynseq.insert_phrase(self.zynseq.scene, params) + self.build_launchers() + self.zyngui.show_screen("launcher") + elif option.startswith("Clone phrase"): + self.zynseq.duplicate_phrase(self.zynseq.scene, params) + self.zynseq.phrase += 1 + self.build_launchers() + self.moving_phrase = True + self.zyngui.show_screen("launcher") + elif option.startswith("Delete phrase"): + self.zyngui.show_confirm(f"Remove phrase {params + 1}?", self.remove_phrase, params) + elif option.startswith("Move phrase"): + self.moving_phrase = True + self.zyngui.show_screen("launcher") + elif option.startswith("Tempo"): + if not params: + params = self.zynseq.get_tempo() + option_screen.enable_param_editor(option_screen, "tempo", { + 'name': 'BPM', + 'is_integer': False, + 'value_min': 10.0, + 'value_max': 420, + 'value': params, + 'nudge_factor': 1.0, + }, assert_cb=self.cb_assert_param_editor) + elif option == "Remove tempo": + self.zynseq.set_sequence_param(self.zynseq.scene, params, zynseq.PHRASE_CHANNEL, "tempo", 0) + index = option_screen.index + self.phrase_menu() + option_screen.select(index - 1) + elif option.startswith("Duration"): + labels = ["DISABLED", "AUTO", "1 bar"] + for i in range(2, 255): + labels.append(f"{i} bars") + if params == 255: + value = 1 + elif params: + value = params + 1 + else: + value = 0 + option_screen.enable_param_editor(option_screen, "duration", { + 'name': 'Duration', + 'value': value, + 'labels': labels, + }, assert_cb=self.cb_assert_param_editor) + elif option.startswith("Beats per bar"): + labels = [] + for i in range(1, 25): + labels.append(f"{i}") + option_screen.enable_param_editor(option_screen, "bpb", { + 'name': 'Beats per bar', + 'value_min': 1, + 'value_max': 24, + 'labels': labels, + 'value': params + }, assert_cb=self.cb_assert_param_editor) + elif option.startswith("Follow action"): + ticks = [] + labels = [] + for phrase in range(self.zynseq.phrases): + offset, title = self.get_follow_info(phrase) + ticks.append(offset) + labels.append(title) + option_screen.enable_param_editor(option_screen, "follow", { + "name": "Follow action", + "labels": labels, + "ticks": ticks, + "value": params + }, assert_cb=self.cb_assert_param_editor) + + def remove_phrase(self, phrase): + self.zynseq.remove_phrase(self.zynseq.scene, phrase) + self.build_launchers() + self.zyngui.show_screen("launcher") + + def drag_launcher(self, dy): + logging.warning(dy) + + def edit_pattern(self): + pated = self.zyngui.screens['pattern_editor'] + pated.refresh_sequence_info() + pated.load_pattern(self.zynseq.libseq.getPattern(self.zynseq.scene, self.zynseq.phrase, self.zynseq.chan, 0, 0)) + #pated.enable_sequence() + self.zyngui.show_screen("pattern_editor") + return True + + def edit_clip(self): + if self.highlighted_strip.chain.chain_id == 0: + self.item_menu() + return True + if type(self.highlighted_strip.chain.midi_chan) is int and self.highlighted_strip.chain.midi_chan < zynseq.PHRASE_CHANNEL: + if self.highlighted_strip.chain.midi_chan > 15: + proc = self.highlighted_strip.chain.get_processors()[0] + proc.engine.set_phrase(proc, self.zynseq.phrase) + self.zyngui.chain_control(self.highlighted_strip.chain.chain_id, proc) + return True + else: + return self.edit_pattern() + + def rename_phrase(self, name): + self.zynseq.set_sequence_param(self.zynseq.scene, self.zynseq.phrase, zynseq.PHRASE_CHANNEL, "name", name) + index = self.zyngui.screens['option'].index + self.phrase_menu() + self.zyngui.screens['option'].select(index) + + def cb_assert_param_editor(self, val=None): + self.send_controller_value(self.zyngui.screens['option'].param_editor_zctrl) + index = self.zyngui.screens['option'].index + self.phrase_menu() + self.zyngui.screens['option'].select(index) + + def send_controller_value(self, zctrl): + """ Handle param editor value change """ + + phrase = self.zynseq.phrase + chan = self.highlighted_strip.chain.midi_chan + if chan is None: + chan = zynseq.PHRASE_CHANNEL + match zctrl.symbol: + case "tempo": + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, zynseq.PHRASE_CHANNEL, "tempo", zctrl.value) + if "CL" in self.chain_manager.zyngines: + # Warp clips in this phrase to match tempo + clippy_engine = self.chain_manager.zyngines["CL"] + for processor in clippy_engine.processors: + try: + pattern = self.zynseq.get_pattern(self.zynseq.scene, phrase, processor.midi_chan, 0, 0) + note = self.zynseq.get_pattern_param(pattern, 0, "val1Start") + if processor.controllers_dict[f"warp {note}"]: + clippy_engine.set_file(processor, note, phrase=phrase) + except: + continue + case "bpb": + #self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "bpb", zctrl.value) + self.zynseq.libseq.setPhraseBPB(self.zynseq.scene, phrase, zctrl.value) + self.zynseq.refresh_state() + case "duration": + if zctrl.value == 1: + value = 255 + elif zctrl.value > 1: + value = zctrl.value - 1 + else: + value = 0 + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "repeat", value) + case "follow": + if zctrl.value == 0: + followAction = zynseq.FOLLOW_ACTION_NONE + followParam = 0 + else: + followAction = zynseq.FOLLOW_ACTION_RELATIVE + followParam = zctrl.value + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "followAction", followAction) + self.zynseq.set_sequence_param(self.zynseq.scene, phrase, chan, "followParam", followParam) # -------------------------------------------------------------------------- # Physical UI Control Management: Pots & switches @@ -1169,40 +1938,41 @@ def switch_select(self, type='S'): returns True if event is managed, False if it's not """ - if self.moving_chain: - self.end_moving_chain() + + if super().switch_select(type): + return True + if self.moving_phrase: + self.end_moving_phrase() + return True elif type == "S": - if self.zynmixer.midi_learn_zctrl: - self.midi_learn_menu() + if self.launcher_mode: + if self.zynseq.phrase < self.zynseq.phrases: + self.highlighted_strip.launchers[self.zynseq.phrase].on_clip_short_press() + else: + self.zyngui.chain_control() else: self.zyngui.chain_control() elif type == "B": - # Chain Options - self.zyngui.screens['chain_options'].setup( - self.zyngui.chain_manager.active_chain_id) - self.zyngui.show_screen('chain_options') + if self.launcher_mode and self.highlighted_strip.chan is not None and self.highlighted_strip.chan < 32 and self.zynseq.phrase < self.zynseq.phrases: + self.edit_clip() + else: + self.item_menu() else: return False return True - # Handle onscreen back button press => Should we use it for entering MIDI learn? - # def backbutton_short_touch_action(self): - # if not self.back_action(): - # self.enter_midi_learn() - def back_action(self): """ Function to handle BACK action returns True if event is managed, False if it's not """ - if self.moving_chain: - self.end_moving_chain() + + if self.moving_phrase: + self.end_moving_phrase() return True - elif self.zynmixer.midi_learn_zctrl: - self.exit_midi_learn() + elif self.param_editor_zctrl: + self.disable_param_editor() return True - else: - return super().back_action() def switch(self, swi, t): """ Function to handle switches press @@ -1211,39 +1981,56 @@ def switch(self, swi, t): returns True if action fully handled or False if parent action should be triggered """ + if swi == 0: if t == "S": if self.highlighted_strip is not None: self.highlighted_strip.toggle_solo() return True - elif t == "B" and self.zynmixer.midi_learn_zctrl: - self.midi_learn_menu() - return True - elif swi == 1: - # if zynthian_gui_config.enable_touch_navigation and self.zynmixer.midi_learn_zctrl: - # return False - # This is ugly, but it's the only way i figured for MIDI-learning "mute" without touch. - # Moving the "learn" button to back is not an option. It's a labeled button on V4!! - if t == "S" and not self.moving_chain: - if self.highlighted_strip is not None: + if self.moving_phrase: + self.end_moving_phrase() + return True + if t == "S": + if self.highlighted_strip is not None and not self.back_action(): self.highlighted_strip.toggle_mute() return True elif t == "B": - if self.zynmixer.midi_learn_zctrl: - self.back_action() + self.toggle_launcher_mode() + return True + elif swi == 2: + if t == "S": + if self.launcher_mode: + self.zyngui.show_screen("tempo") else: - self.zyngui.cuia_screen_zynpad() - return True - + self.zyngui.screens["chain_options"].insert_chain() + return True elif swi == 3: return self.switch_select(t) return False + def cuia_v5_zynpot_switch(self, params): + i = params[0] + t = params[1].upper() + if t == 'S': + if i == 2: + self.zyngui.screens["chain_options"].insert_chain() + else: + self.zyngui.zynswitch_short(i) + return True + # Bold knob#2 => chain options + elif t == 'B' and i == 2: + self.zyngui.show_screen("chain_options") + return True + return False + def setup_zynpots(self): - for i in range(zynthian_gui_config.num_zynpots): - lib_zyncore.setup_behaviour_zynpot(i, 0) + if zynthian_gui_config.num_zynpots > 3: + npots = len(self.ctrl_order) + for i in range(npots - 1): + lib_zyncore.setup_behaviour_zynpot(self.ctrl_order[i], 0) + lib_zyncore.setup_behaviour_zynpot(self.ctrl_order[npots - 1], 1) def zynpot_cb(self, i, dval): """ Function to handle zynpot callback @@ -1251,185 +2038,142 @@ def zynpot_cb(self, i, dval): if not self.shown: return - # LAYER encoder adjusts selected chain's level - if i == 0: + # Handle parameter editor + if super().zynpot_cb(i, dval): + return + + # Knob#1 adjusts selected chain's level + elif i == 0: if self.highlighted_strip is not None: self.highlighted_strip.nudge_volume(dval) - # BACK encoder adjusts selected chain's balance/pan - if i == 1: + # Knob#2 adjusts selected chain's balance/pan + elif i == 1: if self.highlighted_strip is not None: self.highlighted_strip.nudge_balance(dval) - # SNAPSHOT encoder adjusts main mixbus level + # Knob#3 adjusts main mixbus level elif i == 2: - self.main_mixbus_strip.nudge_volume(dval) + if self.launcher_mode: + if dval < 0: + self.arrow_up(-dval) + else: + self.arrow_down(-dval) + else: + self.chain_strips[-1].nudge_volume(dval) - # SELECT encoder moves chain selection + # Knob#4 moves chain selection elif i == 3: - if self.moving_chain: - self.zyngui.chain_manager.move_chain(dval) - self.refresh_visible_strips() + if self.moving_phrase: + if dval < 0: + self.arrow_up(-dval) + elif dval > 0: + self.arrow_down(-dval) else: - self.zyngui.chain_manager.next_chain(dval) - self.highlight_active_chain() + self.chain_manager.next_chain(dval) def arrow_left(self): """ Function to handle CUIA ARROW_LEFT """ - if self.moving_chain: - self.zyngui.chain_manager.move_chain(-1) - self.refresh_visible_strips() - else: - self.zyngui.chain_manager.previous_chain() - self.highlight_active_chain() + self.chain_manager.previous_chain() def arrow_right(self): """ Function to handle CUIA ARROW_RIGHT """ - if self.moving_chain: - self.zyngui.chain_manager.move_chain(1) - self.refresh_visible_strips() - else: - self.zyngui.chain_manager.next_chain() - self.highlight_active_chain() + self.chain_manager.next_chain() - def arrow_up(self): + def arrow_up(self, nudge=1): """ Function to handle CUIA ARROW_UP """ - if self.highlighted_strip is not None: - self.highlighted_strip.nudge_volume(1) + if self.param_editor_zctrl: + self.zynpot_cb(self.ctrl_order[3], 1) + elif self.launcher_mode: + if self.zynseq.phrase > 0: + if self.moving_phrase: + self.zynseq.swap_phrase(self.zynseq.scene, self.zynseq.phrase, self.zynseq.phrase - nudge) + self.build_launchers() + self.highlight_launcher() + else: + self.select_launcher(self.zynseq.phrase - nudge) + else: + if self.highlighted_strip is not None: + self.highlighted_strip.nudge_volume(nudge) - def arrow_down(self): + def arrow_down(self, nudge=-1): """ Function to handle CUIA ARROW_DOWN """ - if self.highlighted_strip is not None: - self.highlighted_strip.nudge_volume(-1) + if self.param_editor_zctrl: + self.zynpot_cb(self.ctrl_order[3], -1) + elif self.launcher_mode: + if self.zynseq.phrase < self.zynseq.phrases: + if self.moving_phrase: + if self.zynseq.phrase < self.zynseq.phrases - 1: + self.zynseq.swap_phrase(self.zynseq.scene, self.zynseq.phrase, self.zynseq.phrase - nudge) + self.build_launchers() + self.highlight_launcher() + else: + self.select_launcher(self.zynseq.phrase - nudge) + else: + if self.highlighted_strip is not None: + self.highlighted_strip.nudge_volume(nudge) def backbutton_short_touch_action(self): if not self.back_action(): self.zyngui.back_screen() - def end_moving_chain(self): + def select_launcher(self, phrase=None): + """ Selects the current launcher + + Args: + phrase: Index of phrase to select (None for current phrase) + """ + + if phrase is None: + phrase = self.zynseq.phrase + if phrase < 0: + phrase = 0 + elif phrase >= self.zynseq.phrases: + phrase = self.zynseq.phrases - 1 + if phrase == self.zynseq.phrase: + return + self.zynseq.select_phrase(phrase) + + def end_moving_phrase(self): if zynthian_gui_config.enable_touch_navigation: self.show_back_button(False) - self.moving_chain = False + self.moving_phrase = False self.strip_drag_start = None - self.refresh_visible_strips() + self.refresh_launchers() - # -------------------------------------------------------------------------- - # GUI Event Management - # -------------------------------------------------------------------------- + # CUIA and alt mode management - def on_wheel(self, event): - """ Function to handle mouse wheel event when not over fader strip - event: Mouse event - """ - if event.num == 5: - if self.mixer_strip_offset < 1: - return - self.mixer_strip_offset -= 1 - elif event.num == 4: - if self.mixer_strip_offset + len(self.visible_mixer_strips) >= self.zyngui.chain_manager.get_chain_count() - 1: - return - self.mixer_strip_offset += 1 - self.highlight_active_chain() + def get_alt_mode(self): + return self.alt_mode - # -------------------------------------------------------------------------- - # MIDI learning management - # -------------------------------------------------------------------------- + def cuia_toggle_alt_mode(self, params=None): + self.alt_mode = not self.alt_mode + self.zyngui.set_global_alt_mode(self.alt_mode) + return True - def toggle_menu(self): - if self.zynmixer.midi_learn_zctrl: - self.midi_learn_menu() + def cuia_chain_control(self, params=None): + if self.alt_mode: + chain_id = 0 else: - self.zyngui.toggle_screen("main_menu") - - def midi_learn_menu(self): - options = {} - try: - strip_id = self.zynmixer.midi_learn_zctrl.graph_path[0] + 1 - if strip_id == 17: - strip_id = "Main" - title = f"MIDI Learn Options ({strip_id})" - except: - title = f"MIDI Learn Options" - - if not self.zynmixer.midi_learn_zctrl: - options["Enable MIDI learn" ] = "enable" + chain_id = self.chain_manager.active_chain.chain_id + self.zyngui.chain_control(chain_id) + return True - if isinstance(self.zynmixer.midi_learn_zctrl, zynthian_controller): - if self.zynmixer.midi_learn_zctrl.is_toggle: - if self.zynmixer.midi_learn_zctrl.midi_cc_momentary_switch: - options["\u2612 Momentary => Latch"] = "latched" - else: - options["\u2610 Momentary => Latch"] = "momentary" - if isinstance(self.zynmixer.midi_learn_zctrl, zynthian_controller): - options[f"Clean MIDI-learn ({self.zynmixer.midi_learn_zctrl.symbol})"] = "clean" - else: - options["Clean MIDI-learn (ALL)"] = "clean" + def update_wsleds(self, leds): + # ALT mode only! + if not self.alt_mode: + return - self.midi_learn_sticky = self.zynmixer.midi_learn_zctrl - self.zyngui.screens['option'].config(title, options, self.midi_learn_menu_cb) - self.zyngui.show_screen('option') + wsl = self.zyngui.wsleds - def midi_learn_menu_cb(self, options, params): - if params == 'clean': - self.midi_unlearn_action() - elif params == 'enable': - self.enter_midi_learn() - self.zyngui.show_screen("audio_mixer") - elif params == "latched": - self.zynmixer.midi_learn_zctrl.midi_cc_momentary_switch = 0 - elif params == "momentary": - self.zynmixer.midi_learn_zctrl.midi_cc_momentary_switch = 1 - - def enter_midi_learn(self, zctrl=True): - self.midi_learn_sticky = None - if self.zynmixer.midi_learn_zctrl == zctrl: - return - self.zynmixer.midi_learn_zctrl = zctrl - if zctrl != True: - self.zynmixer.enable_midi_learn(zctrl) - self.refresh_visible_strips() - if zynthian_gui_config.enable_touch_navigation: - self.show_back_button(True) - - def exit_midi_learn(self): - if self.zynmixer.midi_learn_zctrl: - self.zynmixer.midi_learn_zctrl = None - self.zynmixer.disable_midi_learn() - self.refresh_visible_strips() - if zynthian_gui_config.enable_touch_navigation: - self.show_back_button(False) - - def toggle_midi_learn(self): - """ Pre-select all controls in a chain to allow selection of actual control to MIDI learn - """ - match self.zynmixer.midi_learn_zctrl: - case True: - self.exit_midi_learn() - case None: - self.enter_midi_learn(True) - case _: - self.enter_midi_learn() - - def midi_unlearn_action(self): - self.midi_learn_sticky = self.zynmixer.midi_learn_zctrl - if isinstance(self.zynmixer.midi_learn_zctrl, zynthian_controller): - self.zyngui.show_confirm( - f"Do you want to clear MIDI-learn for '{self.zynmixer.midi_learn_zctrl.name}' control?", - self.midi_unlearn_cb, self.zynmixer.midi_learn_zctrl) - else: - self.zyngui.show_confirm( - "Do you want to clean MIDI-learn for ALL mixer controls?", self.midi_unlearn_cb) + # ALT button + wsl.set_led(leds[0], wsl.wscolor_active2) - def midi_unlearn_cb(self, zctrl=None): - if zctrl: - self.zynmixer.midi_unlearn(zctrl) - else: - self.zynmixer.midi_unlearn_all() - self.zynmixer.midi_learn_zctrl = True - self.refresh_visible_strips() + # CTRL button + wsl.set_led(leds[15], wsl.wscolor_active2) # -------------------------------------------------------------------------- diff --git a/zyngui/zynthian_gui_option.py b/zyngui/zynthian_gui_option.py index bc9d7031a..6b18c434b 100644 --- a/zyngui/zynthian_gui_option.py +++ b/zyngui/zynthian_gui_option.py @@ -47,18 +47,27 @@ def __init__(self): self.close_on_select = True super().__init__("Menu") - def config(self, title, options, cb_select, close_on_select=True, click_type=False, index=0): - self.title = title + def config(self, title, options, cb_select, close_on_select=True, click_type=False, index=None): + reset_index = False + if title != self.title: + self.title = title + reset_index = True if callable(options): self.options_cb = options + reset_index = True self.options = None else: self.options_cb = None + if len(options) != len(self.options): + reset_index = True self.options = options self.cb_select = cb_select self.close_on_select = close_on_select self.click_type = click_type - if index is not None: + if index is None: + if reset_index: + self.index = 0 + else: self.index = index def config_file_list(self, title, dpaths, fpat, cb_select, close_on_select=True, click_type=False): diff --git a/zyngui/zynthian_gui_pated_base.py b/zyngui/zynthian_gui_pated_base.py new file mode 100644 index 000000000..b855d36d9 --- /dev/null +++ b/zyngui/zynthian_gui_pated_base.py @@ -0,0 +1,1753 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian GUI +# +# Zynthian GUI Step-Sequencer Pattern Editor Base Class +# +# Copyright (C) 2015-2025 Fernando Moyano +# Brian Walton +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import os +import copy +import tkinter +import logging +from datetime import datetime +import tkinter.font as tkfont + +# Zynthian specific modules +from zyncoder.zyncore import lib_zyncore +from zynlibs.zynseq import zynseq +from zynlibs.zynsmf import zynsmf +from zyngui import zynthian_gui_base +from zyngui import zynthian_gui_config + +# ------------------------------------------------------------------------------ + +# Local constants +SELECT_BORDER = zynthian_gui_config.color_on +PLAYHEAD_CURSOR = zynthian_gui_config.color_on +CANVAS_BACKGROUND = zynthian_gui_config.color_panel_bd +GRID_LINE_WEAK = "#505050" +GRID_LINE_STRONG = "#A0A0A0" +GRID_LINE_XTRONG = "#FFFFFF" +PLAYHEAD_BACKGROUND = zynthian_gui_config.color_variant(zynthian_gui_config.color_panel_bd, 40) +PLAYHEAD_LINE = zynthian_gui_config.color_tx_off +PLAYHEAD_HEIGHT = 12 +CONFIG_ROOT = "/zynthian/zynthian-data/zynseq" + +DRAG_SENSIBILITY = 1.5 +SAVE_SNAPSHOT_DELAY = 10 + +EDIT_MODE_NONE = 0 # Edit mode disabled +EDIT_MODE_SINGLE = 1 # Edit mode enabled for selected note +EDIT_MODE_MULTI = 2 # Edit mode enabled for a selection of notes (or ALL) +EDIT_MODE_ZOOM = 3 # Zoom mode +EDIT_MODE_HISTORY = 4 # Edit history mode (undo/redo) +EDIT_MODE_BLOCK = 5 # Block edit mode + +# List of permissible steps per beat +STEPS_PER_BEAT = [1, 2, 3, 4, 6, 8, 12, 24] +# List of quantization divisors +QUANTIZATION_DIVISORS = [0, 1, 2, 3, 4, 6, 8] +QUANTIZATION_LABELS = ["DISABLED", "1 step", "1/2 step", "1/3 step", "1/4 step", "1/6 step", "1/8 step"] +# List of available MIDI channels +INPUT_CHANNEL_LABELS = ['OFF', 'ANY', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16'] + +# ------------------------------------------------------------------------------ +# Zynthian Step-Sequencer Pattern Editor Base GUI Class +# ------------------------------------------------------------------------------ + + +class zynthian_gui_pated_base(zynthian_gui_base.zynthian_gui_base): + + DEFAULT_VIEW_STEPS = 16 + DEFAULT_VIEW_ROWS = 16 + + # Function to initialise class + def __init__(self): + super().__init__() + self.zynseq_dpath = os.environ.get('ZYNTHIAN_DATA_DIR', "/zynthian/zynthian-data") + "/zynseq" + self.patterns_dpath = self.zynseq_dpath + "/patterns" + self.my_zynseq_dpath = os.environ.get('ZYNTHIAN_MY_DATA_DIR', "/zynthian/zynthian-my-data") + "/zynseq" + self.my_patterns_dpath = self.my_zynseq_dpath + "/patterns" + self.my_captures_dpath = os.environ.get('ZYNTHIAN_MY_DATA_DIR', "/zynthian/zynthian-my-data") + "/capture" + + self.state_manager = self.zyngui.state_manager + self.zynseq = self.state_manager.zynseq + + self.ctrl_order = zynthian_gui_config.layout['ctrl_order'] + + self.title = "Pattern 0" + self.alt_mode = False + self.edit_mode = EDIT_MODE_NONE # Enable encoders to adjust note parameters + self.clipboard = 8 * [None] # Pattern clipboard: Array of pattern indexes to copy/paste. + self.phrase = 0 # Phrase where pattern is used + self.pattern = 0 # Pattern to edit + self.sequence = 0 # Sequence used for pattern editor sequence player + self.channel = 0 + self.seq_info = {} # Launcher sequence info - None to use phrase 0xffff, sequence 0 + self.last_menu_options = {} # Last menu options (indexes) saved for each pattern. May be dirty, but we want good UX ;-) + + self.playhead = 0 + self.playstate = zynseq.SEQ_STOPPED + self.n_steps = 0 # Number of steps in current pattern + self.n_steps_beat = 0 # Number of steps per beat (current pattern) + self.step_offset = 0 # Step number of left column in grid + self.selected_cell = [0, 60] + self.rect_selected_cell = None # Rectangle object selected cell + self.grid_rows = 0 + self.grid_steps = 0 + + # Block edit mode => Copy/paste pattern blocks + self.block_cell_start = None # Block start for copy/past (block edit mode) + self.block_cell_end = None # Block start for copy/past (block edit mode) + self.rect_selected_block = None # Rectangle tkinter object for block edit mode + self.block_copied = None # Coordinates of copied block (list of lists) + self.block_dstep = None # Horizontal offset of block block when moving around + self.block_drow = None # Horizontal offset of block block when moving around + self.selected_events = None # List of indexes of selected events + + # What to redraw: 0=nothing, 1=selected cell, 2=selected row, 3=refresh grid, 4=rebuild grid + self.redraw_pending = 4 + self.drawing = False # mutex to avoid concurrent screen draws + self.changed = False + self.changed_ts = 0 + self.midi_record = False # True when record from MIDI enabled + + # Touch control variables + self.swiping = 0 + self.swipe_friction = 0.8 + self.swipe_step_dir = 0 + self.swipe_row_dir = 0 + self.swipe_step_speed = 0 + self.swipe_row_speed = 0 + self.swipe_step_offset = 0 + self.swipe_row_offset = 0 + self.grid_drag_start = None # Coordinates at start of grid drag + self.grid_drag_count = 0 + self.piano_roll_drag_start = None + self.piano_roll_drag_count = 0 + + # Geometry contants + self.grid_height = self.height - PLAYHEAD_HEIGHT + self.grid_width = int(self.width * 0.91) + self.piano_roll_width = self.width - self.grid_width + # Scale thickness of select border based on screen resolution + self.select_thickness = 1 + int(self.width / 500) + # Zoom factor => Negative / Zero / Positive + self.zoom = 0 + # Geometry variables => change with zoom factor! + self.base_row_height = self.grid_height // self.DEFAULT_VIEW_ROWS + self.base_step_width = self.grid_width // self.DEFAULT_VIEW_STEPS + # Quantity of columns (steps) displayed in grid + self.view_steps = self.DEFAULT_VIEW_STEPS + self.step_width = self.base_step_width + # Quantity of rows (notes) displayed in grid + self.n_rows = 36 + self.view_rows = self.DEFAULT_VIEW_ROWS + self.row_height = self.base_row_height + + # Create pattern grid canvas + self.grid_canvas = tkinter.Canvas(self.main_frame, + width=self.grid_width, + height=self.grid_height, + scrollregion=(0, 0, self.grid_width, self.grid_height), + bg=CANVAS_BACKGROUND, + bd=0, + highlightthickness=0) + self.update_geometry() + self.grid_canvas.grid(column=1, row=0) + self.grid_canvas.bind('', self.on_grid_press) + self.grid_canvas.bind('', self.on_grid_release) + self.grid_canvas.bind('', self.on_grid_drag) + self.grid_canvas.bind('', self.on_grid_wheel) + self.grid_canvas.bind('', self.on_grid_wheel) + self.zyngui.multitouch.tag_bind(self.grid_canvas, None, "gesture", self.on_gesture) + + + # Create pianoroll canvas + self.piano_roll = tkinter.Canvas(self.main_frame, + width=self.piano_roll_width, + height=self.grid_height, + scrollregion=(0, 0, self.piano_roll_width, self.total_height), + bg=CANVAS_BACKGROUND, + bd=0, + highlightthickness=0) + self.piano_roll.grid(row=0, column=0) + self.piano_roll.bind("", self.on_pianoroll_press) + self.piano_roll.bind("", self.on_pianoroll_release) + self.piano_roll.bind("", self.on_pianoroll_motion) + self.piano_roll.bind("", self.on_pianoroll_wheel) + self.piano_roll.bind("", self.on_pianoroll_wheel) + + # Create playhead canvas + self.play_canvas = tkinter.Canvas(self.main_frame, + width=self.grid_width, + height=PLAYHEAD_HEIGHT, + scrollregion=(0, 0, self.grid_width, PLAYHEAD_HEIGHT), + bg=PLAYHEAD_BACKGROUND, + bd=0, + highlightthickness=0) + self.play_canvas.create_rectangle(0, 0, self.step_width, PLAYHEAD_HEIGHT, + fill=PLAYHEAD_CURSOR, + state="normal", + width=0, + tags="playCursor") + self.play_canvas.grid(column=1, row=1) + + # Create velocity level indicator canvas + self.velocity_canvas = tkinter.Canvas(self.main_frame, + width=self.piano_roll_width, + height=PLAYHEAD_HEIGHT, + bg=PLAYHEAD_BACKGROUND, + bd=0, + highlightthickness=0) + self.velocity_canvas.create_rectangle(0, 0, 0, PLAYHEAD_HEIGHT, fill='yellow', width=0, + tags="velocityIndicator") + self.velocity_canvas.grid(column=0, row=1) + + # Configure ALT mode layout depending on hardware + if zynthian_gui_config.check_wiring_layout(["V5"]): + self.switch_i_clipboard = [11, 15, 19, 23] + self.wsleds_i_clipboard = [10, 11, 12, 13] + elif zynthian_gui_config.check_wiring_layout(["Z2"]): + self.switch_i_clipboard = [10, 11, 12, 13] + self.wsleds_i_clipboard = [10, 11, 12, 13] + elif zynthian_gui_config.check_kit_version(["V4"]): + self.switch_i_clipboard = None + self.wsleds_i_clipboard = None + else: + self.switch_i_clipboard = None + self.wsleds_i_clipboard = None + + # Function to get name of this view + def get_name(self): + return "pattern editor base" + + # Function to set up behaviour of encoders + def setup_zynpots(self): + for i in range(zynthian_gui_config.num_zynpots): + lib_zyncore.setup_behaviour_zynpot(i, 0) + + def get_title(self): + seq_name = self.zynseq.get_sequence_name(self.zynseq.scene, self.phrase, self.sequence) + #logging.debug(f"BANK: {bank}, SEQUENCE: {sequence}") + if seq_name: + try: + synth_chain = self.zyngui.chain_manager.get_synth_chain(self.channel) + chain_name = synth_chain.get_title() + except: + chain_name = "" + if not chain_name: + chain_name = f"MIDI-{self.channel + 1}" + return f"{seq_name} {chain_name}" + else: + return f"Pattern {self.pattern}" + + def set_title(self, title=None, color_fg=None, color_bg=None, timeout=None): + if not title: + title = self.get_title() + if not color_fg: + color_fg = zynthian_gui_config.color_panel_tx + if not color_bg: + color_bg = zynthian_gui_config.color_header_bg + super().set_title(title, color_fg, color_bg, timeout) + + def get_evnum_from_row(self, row): + return row + + def get_row_from_evnum(self, num): + return + + # Function to enable edit mode => It *MUST* be redefined in child class + # mode: Edit mode to enable [EDIT_MODE_NONE | others to define in child classes] + def set_edit_mode(self, mode): + self.edit_mode = mode + color_fg = zynthian_gui_config.color_header_bg + color_bg = zynthian_gui_config.color_panel_tx + if mode == EDIT_MODE_SINGLE: + #self.set_title("Note Parameters", color_fg, color_bg) + self.set_edit_title() + elif mode == EDIT_MODE_MULTI: + #self.set_title("Note Parameters ALL", color_fg, color_bg) + self.set_edit_title() + elif self.edit_mode == EDIT_MODE_ZOOM: + self.set_title("Grid zoom", color_fg, color_bg) + elif self.edit_mode == EDIT_MODE_HISTORY: + self.set_title("Undo/Redo", color_fg, color_bg) + self.init_buttonbar([("ARROW_LEFT", "<< undo"), ("ARROW_RIGHT", "redo >>")]) + elif self.edit_mode == EDIT_MODE_BLOCK: + if self.block_copied: + self.set_title("Paste", color_fg, color_bg) + else: + self.set_title("Cut/Copy/Select", color_fg, color_bg) + self.start_select_block() + #self.init_buttonbar(...) + else: + self.set_title() + self.init_buttonbar() + + def set_edit_title(self): + #step = self.selected_cell[0] + delta = "1" + zynpot = 2 + self.init_buttonbar([(f"ZYNPOT {zynpot},-1", f"-{delta}"), + (f"ZYNPOT {zynpot},+1", f"+{delta}"), + ("ZYNPOT 3,-1", "PREV\nPARAM"), + ("ZYNPOT 3,+1", "NEXT\nPARAM"), + (3, "OK")]) + + # Function to show GUI + def build_view(self): + self.zynseq.libseq.selectSequence(self.zynseq.scene, self.phrase, self.sequence) + # Temporarily set sequence to loop - do not update cache which is used to restore configured state on hide + self.zynseq.libseq.setSequenceFollowAction(self.zynseq.scene, self.phrase, self.sequence, zynseq.FOLLOW_ACTION_RELATIVE) + self.zynseq.libseq.setSequenceFollowParam(self.zynseq.scene, self.phrase, self.sequence, 0) + + self.setup_zynpots() + if not self.param_editor_zctrl: + self.set_title() + + # Set active the first chain with pattern's MIDI chan + try: + chain_id = self.zyngui.chain_manager.get_chain_ids_by_midi_chan(self.channel)[0] + self.zyngui.chain_manager.set_active_chain_by_id(chain_id) + except: + logging.error(f"Couldn't set active chain to channel {self.channel}.") + + self.toggle_midi_record(self.midi_record) + self.redraw_pending = 4 + return True + + # Function to hide GUI + def hide(self): + if not self.shown: + return + self.toggle_midi_record(False) + self.set_edit_mode(EDIT_MODE_NONE) + #self.zynseq.libseq.setRefNote(int(self.keymap_offset)) + self.zynseq.libseq.setPatternZoom(self.zoom) + if self.seq_info: + # Restore sequence (was changed to looping mode for pattern editing) + #self.update_squence_params(["followAction", "followParam", "repeat"]) + self.zynseq.libseq.setSequenceFollowAction(self.zynseq.scene, self.phrase, self.sequence, self.seq_info["followAction"]) + self.zynseq.libseq.setSequenceFollowParam(self.zynseq.scene, self.phrase, self.sequence, self.seq_info["followParam"]) + self.zynseq.libseq.setSequenceRepeat(self.zynseq.scene, self.phrase, self.sequence, self.seq_info["repeat"]) + else: + self.stop_playback() + self.zynseq.refresh_state() + super().hide() + + # ------------------------------------------------------------------------- + # Pattern menu + # ------------------------------------------------------------------------- + + def get_pattern_length(self, beats=None, bpb=None): + if beats is None: + beats = self.zynseq.libseq.getBeatsInPattern(self.pattern) + if bpb is None: + bpb = 4 + if bpb > 1: + bars = beats // bpb + else: + bars = 0 + extra_beats = beats % bpb + + if extra_beats == 0: + beats_text = "" + elif extra_beats == 1: + beats_text = "1 beat" + else: + beats_text = f"{extra_beats} beats" + if bars == 0: + bars_text = "" + elif bars == 1: + bars_text = "1 bar" + else: + bars_text = f"{bars} bars" + if bars and extra_beats: + return f"{bars_text} + {beats_text}" + else: + return bars_text + beats_text + + def get_menu_options(self): + menu_options = {} + extra_options = not zynthian_gui_config.check_wiring_layout(["Z2", "V5"]) + # Global Options + # Sequence options + if self.seq_info: + options = {} + name = self.seq_info["name"] + repeat = self.seq_info["repeat"] + follow_action = self.seq_info["followAction"] + follow_param = self.seq_info["followParam"] + # TODO: Configure start and stop modes + if repeat > 0: + if follow_action == zynseq.FOLLOW_ACTION_RELATIVE: + if follow_param == 0: + options["Play mode (LOOP)"] = "Playmode" + else: + if repeat == 1: + options["Play mode (ONESHOT)"] = "Playmode" + else: + options[f"Play mode (PLAY {repeat} TIMES)"] = "Playmode" + else: + options["Play mode (DISABLED)"] = "Playmode" + program_change = self.zynseq.libseq.getProgramChange(0) + if program_change > 127: + program_change = "None" + options[f"Program Change ({program_change})"] = 'Program Change' + if name: + options[f"Rename ({name})"] = 'Rename sequence' + else: + options[f"Rename"] = 'Rename sequence' + menu_options['_SEQUENCE'] = options + # Pattern Options + options = {} + if extra_options: + if self.get_name() == "pattern editor": + options['\u2610 CC editor'] = 'CC editor' + else: + options['\u2612 CC editor'] = 'CC editor' + options[f"Length ({self.get_pattern_length()})"] = 'Length' + options[f"Steps/Beat ({self.n_steps_beat})"] = 'Steps per beat' + qn = self.zynseq.libseq.getQuantizeNotes() + if qn <= 0: + qval = "DISABLED" + elif qn == 1: + qval = "1 step" + elif qn > 1: + qval = f"1/{self.zynseq.libseq.getQuantizeNotes()} step" + options[f"Quantization ({qval})"] = 'Quantization' + options[f"Swing Amount ({int(100.0 * self.zynseq.libseq.getSwingAmount())}%)"] = 'Swing Amount' + options[f"Swing Divisor ({self.zynseq.libseq.getSwingDiv()})"] = 'Swing Divisor' + options[f"Time Humanization ({int(100.0 * self.zynseq.libseq.getHumanTime())})"] = 'Time Humanization' + menu_options['PATTERN'] = options + # Pattern Edit + options = {} + if not self.zyngui.multitouch._f_device: + options['Grid zoom'] = 'Grid zoom' + if extra_options: + if self.zynseq.libseq.isMidiRecord(): + options['\u2612 Record from MIDI'] = 'Record MIDI' + else: + options['\u2610 Record from MIDI'] = 'Record MIDI' + if self.seq_info: + name = self.zynseq.get_sequence_name(self.zynseq.scene, self.phrase, self.sequence) + options[f"Copy this ({name}) to clipboard#1"] = ('Copy pattern', 0) + for i, paste in enumerate(self.clipboard): + if paste is not None and paste[2] != self.pattern: + name = self.zynseq.get_sequence_name(self.zynseq.scene, paste[0], paste[1]) + options[f"Paste {name} from clipboard#{i+1}"] = ('Paste pattern', i) + options['Load pattern'] = 'Load pattern' + options['Save pattern'] = 'Save pattern' + options['Export to SMF'] = 'Export to SMF' + options['Clear pattern ALL'] = 'Clear pattern ALL' + menu_options['EDIT'] = options + return menu_options + + # Function to add menus + def show_menu(self): + self.disable_param_editor() + menu_options = self.get_menu_options() + options = {} + for subtitle, subopts in menu_options.items(): + if subtitle[0] != "_": + options[f"> {subtitle}"] = None + options.update(subopts) + title = "Sequence options" + if self.seq_info and self.seq_info["name"]: + title += ": " + self.seq_info["name"] + + self.zyngui.screens['option'].config(title, options, self.menu_cb, index=self.get_last_menu_option()) + self.zyngui.show_screen('option') + + def toggle_menu(self): + if self.shown: + self.show_menu() + elif self.zyngui.get_current_screen() == "option": + self.zyngui.close_screen() + + def save_last_menu_option(self): + self.last_menu_options[self.pattern] = self.zyngui.screens['option'].index + + def get_last_menu_option(self): + try: + return self.last_menu_options[self.pattern] + except: + return 0 + + def menu_cb(self, option, params): + #self.save_last_menu_option() => Include this in children classes + if isinstance(params, str): + param = params + elif len(params) > 1: + param = params[0] + else: + return + match param: + case 'Grid zoom': + self.enable_param_editor(self, 'zoom', {'name': 'Zoom', 'value_min': 1, 'value_max': 64, + 'value_default': 1, 'value': self.zoom}) + case 'Tempo': + self.zyngui.show_screen('tempo') + case 'CC editor': + self.zyngui.toggle_pated() + case 'Length': + labels = [] + n_beats = self.zynseq.libseq.getBeatsInPattern(self.pattern) + for i in range(1, 65): + labels.append(self.get_pattern_length(i, self.bpb)) + self.enable_param_editor(self, 'bip', {'name': 'Length', 'value_min': 1, 'value_max': 64, + 'value_default': 4, 'labels': labels, 'value': n_beats}, + assert_cb=self.assert_beats_in_pattern) + case 'Steps per beat': + self.enable_param_editor(self, 'spb', {'name': 'Steps per beat', 'ticks': STEPS_PER_BEAT, + 'value_default': 3, 'value': self.n_steps_beat}, + assert_cb=self.assert_steps_per_beat) + case 'Quantization': + self.enable_param_editor(self, 'quantization', {'name': 'Quantization Divisor', + 'ticks': QUANTIZATION_DIVISORS, 'labels': QUANTIZATION_LABELS, + 'value': self.zynseq.libseq.getQuantizeNotes()}) + case 'Swing Amount': + self.enable_param_editor(self, 'swing_amount', {'name': 'Swing Amount', + 'value_min': 0, 'value_max': 100, + 'value': int(100.0 * self.zynseq.libseq.getSwingAmount()), + 'value_default': 0}) + case 'Swing Divisor': + self.enable_param_editor(self, 'swing_div', {'name': 'Swing Divisor', 'value_min': 1, + 'value_max': self.n_steps_beat, 'value_default': 1, + 'value': self.zynseq.libseq.getSwingDiv()}) + case 'Time Humanization': + self.enable_param_editor(self, 'human_time', {'name': 'Time Humanization', 'value_min': 0, 'value_max': 100, + 'value': int(100.0 * self.zynseq.libseq.getHumanTime()), + 'value_default': 0}) + case 'Record MIDI': + self.toggle_midi_record() + case 'Copy pattern': + self.copy_pattern(params[1]) + case 'Paste pattern': + self.paste_pattern(params[1]) + case 'Load pattern': + self.zyngui.screens['option'].config_file_list("Load pattern", + [self.patterns_dpath, self.my_patterns_dpath], + "*.zpat", self.load_pattern_file) + self.zyngui.show_screen('option') + case 'Save pattern': + self.zyngui.show_keyboard(self.save_pattern_file, "pat#{}".format(self.pattern)) + case 'Export to SMF': + self.zyngui.show_keyboard(self.export_smf, "pat#{}".format(self.pattern)) + case 'Clear pattern ALL': + self.clear_pattern_all() + + # Sequence options + case "Playmode": + labels = ["DISABLED", "LOOP", "ONESHOT"] + for i in range(2, 25): + labels.append(f"PLAY {i} TIMES") + follow_action = self.seq_info["followAction"] + follow_param = self.seq_info["followParam"] + repeat = self.seq_info["repeat"] + if repeat == 0: + value = 0 # disabled + elif follow_action == zynseq.FOLLOW_ACTION_RELATIVE and follow_param == 0: + value = 1 + else: + value = 1 + repeat + self.enable_param_editor(self, "playmode", {'name': 'Playmode', 'value': value, 'labels': labels}, + assert_cb=self.assert_playmode) + case "Rename sequence": + name = self.zynseq.get_sequence_name(self.zynseq.scene, self.phrase, self.sequence) + self.zyngui.show_keyboard(self.rename_sequence, name, 8) + case 'Program Change': + program = self.zynseq.libseq.getProgramChange(0) + 1 + if program > 128: + program = 0 + labels = ["None"] + for i in range(128): + labels.append(f"{i}") + self.enable_param_editor( + self, + 'prog_change', + { + 'name': 'Program', + 'labels': labels, + 'value': program + }, self.add_program_change) + + def send_controller_value(self, zctrl): + match zctrl.symbol: + case 'zoom': + self.set_grid_zoom(zctrl.value) + self.param_editor_zctrl.value = self.zoom + case 'quantization': + self.zynseq.libseq.setQuantizeNotes(zctrl.value) + case 'swing_amount': + self.zynseq.libseq.setSwingAmount(zctrl.value / 100.0) + case 'swing_div': + self.zynseq.libseq.setSwingDiv(zctrl.value) + case 'human_time': + self.zynseq.libseq.setHumanTime(zctrl.value / 100.0) + case 'copy': + self.load_pattern(zctrl.value) + + # Function to assert steps per beat + def assert_steps_per_beat(self, value): + self.zyngui.show_confirm("Changing steps per beat may alter timing and/or lose notes?", + self.set_steps_per_beat, value) + + # Function to actually change steps per beat + def set_steps_per_beat(self, value): + self.zynseq.libseq.setStepsPerBeat(value) + self.zynseq.libseq.resetPatternSnapshots() + self.n_steps_beat = self.zynseq.libseq.getStepsPerBeat() + self.n_steps = self.zynseq.libseq.getSteps() + self.update_geometry() + self.redraw_pending = 4 + + # Function to assert beats in pattern + def assert_beats_in_pattern(self, value): + if self.zynseq.libseq.getLastStep() >= self.zynseq.libseq.getStepsPerBeat() * value: + self.zyngui.show_confirm("Reducing beats in pattern will truncate pattern", + self.set_beats_in_pattern, value) + else: + self.set_beats_in_pattern(value) + + # Function to assert beats in pattern + def set_beats_in_pattern(self, value): + self.zynseq.libseq.setBeatsInPattern(self.pattern, value) + self.zynseq.libseq.resetPatternSnapshots() + self.n_steps = self.zynseq.libseq.getSteps() + self.update_geometry() + self.redraw_pending = 4 + + # Function to get the index of the closest steps per beat in array of allowed values + # returns: Index of the closest allowed value + def get_steps_per_beat_index(self): + steps_per_beat = self.zynseq.libseq.getStepsPerBeat() + for index in range(len(STEPS_PER_BEAT)): + if STEPS_PER_BEAT[index] >= steps_per_beat: + return index + return len(STEPS_PER_BEAT) - 1 + + # ------------------------------------------------------------------------- + # Sequence management + # ------------------------------------------------------------------------- + + def refresh_sequence_info(self): + """ + Refresh local sequence info for launcher sequences + """ + + try: + self.phrase = self.zynseq.phrase + self.sequence = self.chain_manager.active_chain.midi_chan + self.channel = self.chain_manager.active_chain.midi_chan + try: + self.bpb = self.zynseq.state["scenes"][self.zynseq.scene]["phrases"][self.phrase]["bpb"] + except: + self.bpb = 0 + if self.bpb == 0: + self.bpb = self.zynseq.bpb + self.seq_info = self.zynseq.state["scenes"][self.zynseq.scene]["phrases"][self.phrase]["sequences"][self.sequence] + except Exception as e: + logging.warning(f"Unable to refresh sequence info for pattern: {e}") + self.channel = 0 + self.seq_info = {} + + def update_sequence_params(self, params): + for key in params: + self.zynseq.set_sequence_param(self.zynseq.scene, self.phrase, self.sequence, key, self.seq_info[key]) + + def update_sequence_info(self): + for key, value in self.seq_info.items(): + self.zynseq.set_sequence_param(self.zynseq.scene, self.phrase, self.sequence, key, value) + + def assert_playmode(self, value): + # Update the cache only so that we can assert on hide + # Disable + if value == 0: + #self.seq_info["followAction"] = zynseq.FOLLOW_ACTION_NONE + #self.seq_info["followParam"] = 0 + self.seq_info["repeat"] = 0 + # Loop + elif value == 1: + self.seq_info["followAction"] = zynseq.FOLLOW_ACTION_RELATIVE + self.seq_info["followParam"] = 0 + self.seq_info["repeat"] = 1 + # Oneshot/Repeat + else: + self.seq_info["followAction"] = zynseq.FOLLOW_ACTION_NONE + self.seq_info["followParam"] = 0 + self.seq_info["repeat"] = value - 1 + + def enable_sequence(self): + if self.seq_info["repeat"] == 0: + self.assert_playmode(1) + self.update_sequence_params(["followAction", "followParam", "repeat"]) + + def disable_sequence(self): + if self.seq_info["repeat"] > 0: + self.assert_playmode(0) + self.update_sequence_params(["followAction", "followParam", "repeat"]) + + def rename_sequence(self, name): + self.zynseq.set_sequence_param(self.zynseq.scene, self.phrase, self.sequence, "name", name) + self.set_title() + + # ------------------------------------------------------------------------- + # Pattern management + # ------------------------------------------------------------------------- + + # Function to load new pattern + # index: Pattern index + def load_pattern(self, index): + # Save zoom value and vertical position in pattern object + self.zynseq.libseq.setPatternZoom(self.zoom) + # Load requested pattern + self.zynseq.libseq.setChannel(self.zynseq.scene, self.phrase, self.sequence, 0, self.channel) + self.zynseq.libseq.selectPattern(index) + self.pattern = index + n_steps = self.zynseq.libseq.getSteps() + n_steps_beat = self.zynseq.libseq.getStepsPerBeat() + if n_steps != self.n_steps or n_steps_beat != self.n_steps_beat: + self.n_steps = n_steps + self.n_steps_beat = n_steps_beat + self.step_offset = 0 + self.update_geometry() + self.redraw_pending = 4 + else: + self.redraw_pending = 3 + if self.selected_cell[0] >= n_steps: + self.selected_cell[0] = int(n_steps) - 1 + self.draw_grid() + self.select_cell() + self.play_canvas.coords("playCursor", 1, 0, 1 + self.step_width, PLAYHEAD_HEIGHT) + self.set_title() + self.set_grid_zoom(self.zynseq.libseq.getPatternZoom()) + + def save_pattern_file(self, fname): + fpath = f"{self.my_patterns_dpath}/{fname}.zpat" + if os.path.exists(fpath): + self.zyngui.show_confirm(f"Do you want to overwrite pattern file '{fname}'?", + self.do_save_pattern_file, fpath) + else: + self.do_save_pattern_file(fpath) + + def do_save_pattern_file(self, fpath): + self.zynseq.save_pattern(self.pattern, fpath) + + def load_pattern_file(self, fname, fpath): + if not self.zynseq.is_pattern_empty(self.pattern): + self.zyngui.show_confirm(f"Do you want to overwrite pattern '{self.pattern}'?", + self.do_load_pattern_file, fpath) + else: + self.do_load_pattern_file(fpath) + + def do_load_pattern_file(self, fpath): + self.zynseq.load_pattern(self.pattern, fpath) + self.changed = False + self.redraw_pending = 4 + + # If changed, save snapshot: + # + right now, if now=True + # + force saving ignoring changed flag + # + each loop, if playing + # + each SAVE_SNAPSHOT_DELAY seconds, if stopped + def save_pattern_snapshot(self, now=True, force=False): + if force or self.changed: + if now or (self.playstate != zynseq.SEQ_STOPPED and self.playhead == 0): + self.zynseq.libseq.savePatternSnapshot() + self.changed = False + self.changed_ts = 0 + elif self.playstate == zynseq.SEQ_STOPPED: + ts = datetime.now() + if self.changed_ts: + if (ts - self.changed_ts).total_seconds() > SAVE_SNAPSHOT_DELAY: + self.zynseq.libseq.savePatternSnapshot() + self.changed = False + self.changed_ts = 0 + else: + self.changed_ts = ts + + def undo_pattern(self): + self.save_pattern_snapshot(now=True, force=False) + if self.zynseq.libseq.undoPattern(): + self.redraw_pending = 3 + + def redo_pattern(self): + if not self.changed and self.zynseq.libseq.redoPattern(): + self.redraw_pending = 3 + + def undo_pattern_all(self): + self.save_pattern_snapshot(now=True, force=False) + if self.zynseq.libseq.undoPatternAll(): + self.redraw_pending = 3 + + def redo_pattern_all(self): + if not self.changed and self.zynseq.libseq.redoPatternAll(): + self.redraw_pending = 3 + + # Function to clear all events on pattern (notes & CC) + def clear_pattern_all(self, params=None): + self.zyngui.show_confirm(f"Clear pattern {self.pattern}?", self.do_clear_pattern_all) + + # Function to actually clear pattern + def do_clear_pattern_all(self, params=None): + self.save_pattern_snapshot(now=True, force=False) + self.zynseq.libseq.clearPattern(self.pattern) + self.save_pattern_snapshot(now=True, force=True) + self.redraw_pending = 3 + self.select_cell() + if self.zynseq.libseq.getPlayState(self.phrase, self.sequence, 0) != zynseq.SEQ_STOPPED: + self.zynseq.libseq.sendMidiCommand(0xB0 | self.channel, 123, 0) # All notes off + + # Function to copy current pattern to clipboard + def copy_pattern(self, i=0): + try: + self.clipboard[i] = (self.phrase, self.sequence, self.pattern) + except: + logging.error(f"Wrong clipboard index => {i}") + + # Function to paste pattern from clipboard + def paste_pattern(self, i=0): + try: + paste = self.clipboard[i] + except: + logging.error(f"Wrong clipboard index => {i}") + return + # Don't paste from None or over itself + if paste is None or paste[2] == self.pattern: + return + # Overwriting an empty pattern doesn't need confirmation + if self.zynseq.libseq.getLastStep() == -1: + self.do_paste_pattern(i) + # Overwriting a busy pattern does need confirmation! + else: + name = self.zynseq.get_sequence_name(self.zynseq.scene, paste[0], paste[1]) + self.zyngui.show_confirm(f"Overwrite this pattern with content from {name}?", + self.do_paste_pattern, i) + + # Function to actually copy pattern + def do_paste_pattern(self, i=0): + try: + paste = self.clipboard[i] + except: + logging.error(f"Wrong clipboard index => {i}") + return + # Don't paste from None or over itself + if paste is None or paste[2] == self.pattern: + return + # Paste from clipboard to current pattern + self.zynseq.libseq.copyPattern(paste[2], self.pattern) + self.load_pattern(self.pattern) + + # Function to export pattern to SMF + def export_smf(self, fname): + smf = zynsmf.libsmf.addSmf() + tempo = self.zynseq.libseq.getTempo() + zynsmf.libsmf.addTempo(smf, 0, tempo) + ticks_per_step = zynsmf.libsmf.getTicksPerQuarterNote(smf) / self.n_steps_beat + for step in range(self.n_steps): + time = int(step * ticks_per_step) + for note in range(128): + duration = self.zynseq.libseq.getNoteDuration(step, note) + if duration == 0.0: + continue + duration = int(duration * ticks_per_step) + velocity = self.zynseq.libseq.getNoteVelocity(step, note) + zynsmf.libsmf.addNote(smf, 0, time, duration, self.channel, note, velocity) + zynsmf.libsmf.setEndOfTrack(smf, 0, int(self.n_steps * ticks_per_step)) + zynsmf.save(smf, "{}/{}.mid".format(self.my_captures_dpath, fname)) + + # Function to add program change at start of pattern + def add_program_change(self, value): + value -= 1 + if value < 0 or value > 127: + self.zynseq.libseq.removeProgramChange(0, value) + else: + self.zynseq.libseq.addProgramChange(0, value) + + def toggle_midi_record(self, midi_record=None): + if midi_record is None: + midi_record = not self.midi_record + self.midi_record = midi_record + self.zynseq.libseq.enableMidiRecord(midi_record) + self.save_pattern_snapshot(now=True, force=False) + + # ------------------------------------------------------------------------- + # Touch event management + # ------------------------------------------------------------------------- + + # Function to handle start of pianoroll drag + def on_pianoroll_press(self, event): + self.swiping = False + self.swipe_step_speed = 0 + self.swipe_row_speed = 0 + self.swipe_step_dir = 0 + self.swipe_row_dir = 0 + self.piano_roll_drag_start = event + self.piano_roll_drag_count = 0 + + # Function to handle pianoroll drag motion + def on_pianoroll_motion(self, event): + if not self.piano_roll_drag_start: + return 0 + self.piano_roll_drag_count += 1 + offset = int(DRAG_SENSIBILITY * (event.y - self.piano_roll_drag_start.y) / self.row_height) + if offset == 0: + return 0 + self.swiping = True + self.piano_roll_drag_start = event + self.swipe_step_dir = 0 + self.swipe_row_dir = offset + return offset + + # Function to handle end of pianoroll drag + def on_pianoroll_release(self, event): + # Play note if not drag action + if self.piano_roll_drag_start and self.piano_roll_drag_count == 0: + self.on_pianoroll_release_action(event) + # Swipe + elif self.swiping: + dts = (event.time - self.piano_roll_drag_start.time)/1000 + self.swipe_nudge(dts) + # Reset drag state variables + self.piano_roll_drag_start = None + self.piano_roll_drag_count = 0 + + def on_pianoroll_release_action(self, event): + pass + + # Function to handle mouse wheel over pianoroll + def on_pianoroll_wheel(self, event): + pass + + # Function to handle grid mouse down + # event: Mouse event + def on_grid_press(self, event): + pass + + # Function to handle grid mouse drag + # event: Mouse event + def on_grid_drag(self, event): + pass + + # Function to handle grid mouse drag + # event: Mouse event + def on_grid_wheel(self, event): + if event.num == 4: + self.set_grid_zoom(self.zoom + 1) + else: + self.set_grid_zoom(self.zoom - 1) + + # Function to handle grid mouse release + # event: Mouse event + def on_grid_release(self, event): + pass + + def on_gesture(self, gtype, value): + pass + + def swipe_nudge(self, dts): + try: + kt = 0.5 * min(0.05 * DRAG_SENSIBILITY / dts, 8) + except: + return + self.swipe_step_speed += kt * self.swipe_step_dir + self.swipe_row_speed += kt * self.swipe_row_dir + # logging.debug(f"KT={kt} => SWIPE_STEP_SPEED = {self.swipe_step_speed}, SWIPE_ROW_SPEED = {self.swipe_row_speed}") + + # Update swipe scroll + def swipe_update(self): + select_cell = False + if self.swipe_step_speed: + # logging.debug(f"SWIPE_UPDATE_STEP => {self.swipe_step_speed}") + self.swipe_step_offset += self.swipe_step_speed + self.swipe_step_speed *= self.swipe_friction + if abs(self.swipe_step_speed) < 0.2: + self.swipe_step_speed = 0 + self.swipe_step_offset = 0 + if abs(self.swipe_step_offset) > 1: + self.step_offset += int(self.swipe_step_offset) + self.swipe_step_offset -= int(self.swipe_step_offset) + self.set_step_offset(self.step_offset) + select_cell = True + if self.swipe_row_speed: + # logging.debug(f"SWIPE_UPDATE_ROW => {self.swipe_row_speed}") + self.swipe_row_offset += self.swipe_row_speed + self.swipe_row_speed *= self.swipe_friction + if abs(self.swipe_row_speed) < 0.2: + self.swipe_row_speed = 0 + self.swipe_row_offset = 0 + if abs(self.swipe_row_offset) > 1: + self.swipe_vertical_action() + select_cell = True + if select_cell: + self.select_cell() + + def swipe_vertical_action(self): + pass + + # ------------------------------------------------------------------------- + # Geometry management + # ------------------------------------------------------------------------- + + def get_pianoroll_num_cells(self): + return 128 + + # Function to calculate variable gemoetry parameters + def update_geometry(self): + # Width & height + self.total_width = self.n_steps * self.step_width + self.total_height = 128 * self.row_height + self.scroll_height = self.total_height - self.grid_height + # Base cell size + self.base_row_height = self.grid_height // self.DEFAULT_VIEW_ROWS + if self.n_steps: + self.base_step_width = self.grid_width // min(self.DEFAULT_VIEW_STEPS, self.n_steps) + else: + self.base_step_width = self.grid_width // self.DEFAULT_VIEW_STEPS + # Font size + self.fontsize_grid = self.row_height // 2 + if self.fontsize_grid > 20: + self.fontsize_grid = 20 # Ugly font scale limiting + self.grid_font = tkfont.Font(family=zynthian_gui_config.font_topbar[0], size=self.fontsize_grid) + self.calculate_geometry_limits() + self.update_scroll_regions() + + def calculate_geometry_limits(self): + # Row height limits + self.max_row_height = self.grid_height // 6 + self.min_row_height = self.grid_height // min(36, max(6, self.n_rows)) + + # Step width limits + self.max_step_width = self.grid_width // 8 + self.min_step_width = self.grid_width // min(64, max(8, self.n_steps)) + + #logging.debug(f"N_STEPS={self.n_steps}") + #logging.debug(f"ROW: MAX={self.max_row_height}, MIN={self.min_row_height}") + #logging.debug(f"STEP: MAX={self.max_step_width}, MIN={self.min_step_width}") + + # Update scrollregion in several canvas + def update_scroll_regions(self): + if self.total_width > 0: + self.grid_canvas.config(scrollregion=(0, 0, self.total_width, self.total_height)) + self.piano_roll.config(scrollregion=(0, 0, self.piano_roll_width, self.total_height)) + self.play_canvas.config(scrollregion=(0, 0, self.total_width, PLAYHEAD_HEIGHT)) + # logging.debug(f"GRID SCROLLREGION: {self.total_width} x {self.total_height}") + + # Function to set step offset and move grid view accordingly + # offset: Step Offset (step at left column) + def set_step_offset(self, offset=None): + if offset is not None: + self.step_offset = offset + if self.n_steps < int(self.view_steps): + self.step_offset = 0 + elif self.step_offset > self.n_steps - int(self.view_steps): + self.step_offset = self.n_steps - int(self.view_steps) + elif self.step_offset < 0: + self.step_offset = 0 + if self.total_width > 0: + xpos = self.step_offset * self.step_width / self.total_width + else: + xpos = 0 + self.grid_canvas.xview_moveto(xpos) + self.play_canvas.xview_moveto(xpos) + #logging.debug(f"OFFSET: {self.step_offset} (NSTEPS: {self.n_steps}, TOTAL WIDTH: {self.total_width})") + #logging.debug(f"GRID X-SCROLL: {xpos}\n\n") + + def set_grid_zoom(self, new_zoom=0): + # self.selected_cell + # Calculate new cell size + step_width = self.base_step_width + new_zoom + row_height = self.base_row_height + new_zoom + # Check step width limits + if step_width > self.max_step_width: + step_width = self.max_step_width + elif step_width < self.min_step_width: + step_width = self.min_step_width + # Check row height limits + if row_height > self.max_row_height: + row_height = self.max_row_height + elif row_height < self.min_row_height: + row_height = self.min_row_height + # Do nothing if nothing changed + if self.step_width != step_width: + self.step_width = step_width + step_width_changed = True + else: + step_width_changed = False + if self.row_height != row_height: + self.row_height = row_height + row_height_changed = True + else: + row_height_changed = False + if not step_width_changed and not row_height_changed: + return False + # Adjust real zoom value + if not step_width_changed: + self.zoom = self.row_height - self.base_row_height + #logging.debug(f"VZOOM! => {self.zoom}") + elif not row_height_changed: + self.zoom = self.step_width - self.base_step_width + #logging.debug(f"HZOOM! => {self.zoom}") + else: + hzoom = self.step_width - self.base_step_width + vzoom = self.row_height - self.base_row_height + if abs(hzoom) > abs(vzoom): + self.zoom = hzoom + else: + self.zoom = vzoom + #logging.debug(f"ZOOM! => {self.zoom} (hzoom={hzoom}, vzoom={vzoom})") + #self.zoom = new_zoom + + # Recalculate geometry parameters and scaling factor + w = self.total_width + h = self.total_height + self.update_geometry() + xscale = self.total_width / w + yscale = self.total_height / h + # Scale canvas + self.grid_canvas.scale("all", 0, 0, xscale, yscale) + self.play_canvas.scale("all", 0, 0, xscale, 1.0) + self.piano_roll.scale("all", 0, 0, 1.0, yscale) + self.update_grid_position(step_width_changed, row_height_changed) + self.select_cell() + return True + + def reset_grid_zoom(self): + self.zoom = 0 + self.view_rows = self.DEFAULT_VIEW_ROWS + self.view_steps = self.DEFAULT_VIEW_STEPS + self.row_height = self.base_row_height + self.step_width = self.base_step_width + w = self.total_width + h = self.total_height + self.update_geometry() + xscale = self.total_width / w + yscale = self.total_height / h + self.grid_canvas.scale("all", 0, 0, xscale, yscale) + self.play_canvas.scale("all", 0, 0, xscale, 1.0) + self.piano_roll.scale("all", 0, 0, 1.0, yscale) + self.reset_grid_offset() + + # Update grid position + def update_grid_position(self, step_width_changed, row_height_changed): + if step_width_changed: + self.set_step_offset() + if row_height_changed: + pass + self.view_rows = self.grid_height / self.row_height + self.view_steps = self.grid_width / self.step_width + + # Reset grid offset + def reset_grid_offset(self): + self.set_step_offset() + + # Function to get cell coordinates + # col: Column number (step) + # row: Row number (keymap index) + # duration: Duration of cell in steps + # offset: Factor to offset start of note + # return: Coordinates required to draw cell + def get_cell(self, col, row, duration, offset): + x1 = int((col + offset) * self.step_width) + 1 + y1 = self.total_height - (row + 1) * self.row_height + 1 + x2 = x1 + int(self.step_width * duration) - 1 + y2 = y1 + self.row_height - 1 + return [x1, y1, x2, y2] + + def get_cell_pos(self, cell): + x1 = int(cell[0] * self.step_width) + 1 + y1 = self.total_height - (cell[1] + 1) * self.row_height + 1 + return [x1, y1] + + # ------------------------------------------------------------------------- + # Drawing functions + # ------------------------------------------------------------------------- + + # Function to draw grid + def draw_grid(self): + if self.drawing: + return + self.drawing = True + + if self.n_steps == 0: + self.redraw_pending = 0 + self.drawing = False + return # TODO: Should we clear grid? + + self.redraw_grid_pending() + self.redraw_pending = 0 + self.select_cell() + self.drawing = False + + def redraw_grid_pending(self): + # Draw cells of grid + # self.grid_canvas.itemconfig("gridcell", fill="black") + if self.redraw_pending > 3: + # Redraw gridlines + self.grid_canvas.delete("gridvline") + self.play_canvas.delete("beatnum") + if self.n_steps_beat: + bnum_font = tkfont.Font(family=zynthian_gui_config.font_topbar[0], size=PLAYHEAD_HEIGHT - 2) + lh = max(128 * self.row_height - 1, self.grid_height - 1) + th = int(0.7 * PLAYHEAD_HEIGHT) + for step in range(0, self.n_steps + 1): + xpos = step * self.step_width + if step % self.n_steps_beat == 0: + beatnum = step // self.n_steps_beat + rest_beatnum = beatnum % self.bpb + if rest_beatnum == 0: + self.grid_canvas.create_line(xpos, 0, xpos, lh, fill=GRID_LINE_XTRONG, tags="gridvline") + else: + self.grid_canvas.create_line(xpos, 0, xpos, lh, fill=GRID_LINE_STRONG, tags="gridvline") + if step < self.n_steps: + if beatnum == 0: + anchor = tkinter.NW + else: + anchor = tkinter.N + self.play_canvas.create_text(xpos, -2, text=str(beatnum + 1), font=bnum_font, anchor=anchor, + fill=GRID_LINE_STRONG, tags="beatnum") + else: + self.grid_canvas.create_line(xpos, 0, xpos, lh, fill=GRID_LINE_WEAK, tags="gridvline") + self.play_canvas.create_line(xpos, 0, xpos, th, fill=PLAYHEAD_LINE, tags="beatnum") + + # Function to draw pianoroll content + def draw_pianoroll(self): + #self.piano_roll.delete(tkinter.ALL) + pass + + def pianoroll_set_row(self, row, color=None): + pass + + def pianoroll_note_on(self, note): + pass + + def pianoroll_note_off(self, note): + pass + + def draw_events(self): + pass + + def draw_cp_events(self): + pass + + def draw_event(self, evdata, cp=False, row=None): + pass + + def draw_row(self, row): + pass + + # Function to draw a grid cell + # step: Step (column) index + # row: Index of row + def draw_cell(self, step, row): + pass + + # Function to update selectedCell + # step: Step (column) of selected cell (Optional - default to reselect current column) + # row: Index of keymap to select (Optional - default to reselect current row). + # Maybe outside visible range to scroll display + # plot: plot cursor (True by default) + def select_cell(self, step=None, row=None): + pass + + def hide_selected_cell(self): + if self.rect_selected_cell: + self.grid_canvas.delete(self.rect_selected_cell) + self.rect_selected_cell = None + + # --------------------------------------------------------------- + # Block edit functionality => Copy/paste block + # --------------------------------------------------------------- + + def _move_cell(self, cell, dstep, drow): + inrange = True + if dstep: + cell[0] += dstep + if cell[0] >= self.n_steps: + cell[0] = self.n_steps - 1 + inrange = False + elif cell[0] < 0: + cell[0] = 0 + inrange = False + if drow: + cell[1] += drow + if cell[1] > 127: + cell[1] = 127 + inrange = False + elif cell[1] < 0: + cell[1] = 0 + inrange = False + return inrange + + def plot_select_block(self): + # Plot rectangle + coord = self.get_cell_pos(self.block_cell_start) + self.get_cell_pos(self.block_cell_end) + if coord[2] >= coord[0]: + coord[2] += self.step_width + else: + coord[0] += self.step_width + if coord[3] >= coord[1]: + coord[3] += self.row_height + else: + coord[1] += self.row_height + if not self.rect_selected_block: + self.rect_selected_block = self.grid_canvas.create_rectangle(coord, fill="", outline=SELECT_BORDER, + width=self.select_thickness, tags="selected_block") + else: + self.grid_canvas.coords(self.rect_selected_block, coord) + + def start_select_block(self): + self.clean_selected_events() + self.block_copied = None + self.block_cell_start = copy.copy(self.selected_cell) + self.block_cell_end = copy.copy(self.selected_cell) + self.select_block(0, 0) + + def end_select_block(self): + self.clean_selected_events() + self.block_copied = None + self.set_edit_mode(EDIT_MODE_NONE) + self.select_cell() + + def select_block(self, dstep, drow): + # Move end position + self._move_cell(self.block_cell_end, dstep, drow) + # Hide cursor + self.hide_selected_cell() + # Position cursor (hidden) + self.select_cell(self.block_cell_end[0], self.block_cell_end[1]) + # Plot + self.plot_select_block() + + def hide_selected_block(self): + if self.rect_selected_block: + self.grid_canvas.delete(self.rect_selected_block) + self.rect_selected_block = None + + def clean_selected_events(self): + if self.selected_events: + self.selected_events = None + self.redraw_pending = 3 + + # End block selection + def _end_block_selection(self): + if self.block_cell_end[0] > self.block_cell_start[0]: + step1 = self.block_cell_start[0] + step2 = self.block_cell_end[0] + else: + step1 = self.block_cell_end[0] + step2 = self.block_cell_start[0] + if self.block_cell_end[1] >= self.block_cell_start[1]: + row1 = self.block_cell_start[1] + row2 = self.block_cell_end[1] + else: + row1 = self.block_cell_end[1] + row2 = self.block_cell_start[1] + self.block_cell_start = [step1, row1] + self.block_cell_end = [step2, row2] + + def copy_block(self, cut=False): + self._end_block_selection() + # if cutting => save snapshot + if cut: + self.save_pattern_snapshot(True, False) + # Copy/Cut subpattern to clipboard + n = self.zynseq.libseq.copyPatternBuffer(self.pattern, + self.block_cell_start[0], self.block_cell_end[0], + self.get_evnum_from_row(self.block_cell_start[1]), + self.get_evnum_from_row(self.block_cell_end[1]), + cut) + # If selection is empty => end select mode + if n == 0: + self.end_select_block() + return + # If selection is not empty => save block coordinates + self.block_copied = [self.block_cell_start, self.block_cell_end] + self.block_dstep = 0 + self.block_drow = 0 + # Hide select block and plot copied notes + self.set_edit_mode(EDIT_MODE_BLOCK) + self.hide_selected_block() + self.draw_cp_events() + # if cutting => redraw pattern notes + if cut: + self.changed = True + self.redraw_pending = 3 + + def select_block_events(self): + self._end_block_selection() + # Get indexes of selected events + self.selected_events = self.zynseq.get_pattern_selection(self.pattern, + self.block_cell_start[0], self.block_cell_end[0], + self.get_evnum_from_row(self.block_cell_start[1]), + self.get_evnum_from_row(self.block_cell_end[1])) + # If selection is empty => end select mode + if not self.selected_events: + self.end_select_block() + return + # Enter EDIT_MODE_MULTI and replot to highlight selected events + #logging.debug(f"SELECTED EVENTS => {self.selected_events}") + self.set_edit_mode(EDIT_MODE_MULTI) + #self.hide_selected_block() + #self.hide_selected_cell() + self.redraw_pending = 3 + + def move_block(self, dstep, drow): + # Calculate new position + pos1 = copy.copy(self.block_cell_start) + pos2 = copy.copy(self.block_cell_end) + # Respect vertical limits, but allow horizontal overflow + if self._move_cell(pos1, 0, drow) and self._move_cell(pos2, 0, drow): + pos1[0] += dstep + pos2[0] += dstep + # Horizontal circular move + if pos1[0] >= self.n_steps: + pos1[0] -= self.n_steps + pos2[0] -= self.n_steps + elif pos2[0] < 0: + pos1[0] += self.n_steps + pos2[0] += self.n_steps + self.block_cell_start = pos1 + self.block_cell_end = pos2 + # Calculate position offset => current position - copy position + self.block_dstep = self.block_cell_start[0] - self.block_copied[0][0] + self.block_drow = self.block_cell_start[1] - self.block_copied[0][1] + # Redraw copied notes in the new position + self.draw_cp_events() + # Position cursor (hidden) to center view area (scroll) + if dstep > 0: + step = self.block_cell_end[0] + else: + step = self.block_cell_start[0] + if drow > 0: + row = self.block_cell_end[1] + else: + row = self.block_cell_start[1] + 1 + self.select_cell(step, row) + + def paste_block(self): + # Save snapshot + self.save_pattern_snapshot(True, False) + # Paste buffer + self.zynseq.libseq.pastePatternBuffer(self.pattern, self.block_dstep, 0.0, self.block_drow, False) # truncate=False to use horizontal circular overflow + self.changed = True + self.redraw_pending = 3 + + # ------------------------------------------------------------------------- + # Event management + # ------------------------------------------------------------------------- + + def plot_zctrls(self): + self.swipe_update() + + # Function to toggle event + # step: step number (column) + # row: row index + # Returns: Event number if event added else None + def toggle_event(self, step, row): + pass + + # Function to remove an event + # step: step number (column) + # row: row index + def remove_event(self, step, row): + pass + + # Function to refresh status + def refresh_status(self): + super().refresh_status() + self.playstate = self.zynseq.libseq.getSequenceState(self.zynseq.scene, self.phrase, self.sequence) & 0xff + step = self.zynseq.libseq.getPatternPlayhead() + if self.playhead != step: + self.playhead = step + self.play_canvas.coords("playCursor", + 1 + self.playhead * self.step_width, 0, + 1 + self.step_width * (self.playhead + 1), PLAYHEAD_HEIGHT) + if (self.zynseq.libseq.isPatternModified()) and self.redraw_pending < 3: + self.redraw_pending = 3 + if self.redraw_pending: + self.draw_grid() + self.save_pattern_snapshot(now=False, force=False) + + # Function to handle zynpots value change + # i: Zynpot index [0..n] + # dval: Current value of zyncoder + def zynpot_cb(self, i, dval): + # This will be called in the child class + #if super().zynpot_cb(i, dval): + # return True + if i == self.ctrl_order[1]: + if self.edit_mode == EDIT_MODE_NONE: + self.set_grid_zoom(self.zoom + dval) + return True + elif i == self.ctrl_order[2]: + if self.edit_mode == EDIT_MODE_BLOCK: + if self.block_copied: + self.move_block(0, -dval) + else: + self.select_block(0, -dval) + return True + elif self.edit_mode == EDIT_MODE_NONE: + self.select_cell(None, self.selected_cell[1] - dval) + return True + elif i == self.ctrl_order[3]: + if self.edit_mode == EDIT_MODE_ZOOM: + self.set_grid_zoom(self.zoom + dval) + return True + elif self.edit_mode == EDIT_MODE_HISTORY: + if dval > 0: + self.redo_pattern() + else: + self.undo_pattern() + return True + elif self.edit_mode == EDIT_MODE_BLOCK: + if self.block_copied: + self.move_block(dval, 0) + else: + self.select_block(dval, 0) + return True + elif self.edit_mode == EDIT_MODE_NONE: + self.select_cell(self.selected_cell[0] + dval, None) + return True + + # Function to handle SELECT button press + # st: Button press duration [S=Short, B=Bold, L=Long] + def switch_select(self, st='S'): + if super().switch_select(st): + return + if st == "S": + if self.edit_mode == EDIT_MODE_NONE: + self.toggle_event(self.selected_cell[0], self.selected_cell[1]) + elif self.edit_mode == EDIT_MODE_BLOCK: + if not self.block_copied: + self.copy_block(cut=False) + else: + self.paste_block() + else: + self.set_edit_mode(EDIT_MODE_NONE) + elif st == "B": + if self.edit_mode == EDIT_MODE_NONE: + self.set_edit_mode(EDIT_MODE_SINGLE) + elif self.edit_mode == EDIT_MODE_SINGLE: + self.set_edit_mode(EDIT_MODE_MULTI) + elif self.edit_mode == EDIT_MODE_BLOCK: + if not self.block_copied: + self.select_block_events() + + # Function to handle switch press + # i: Switch index [0=Layer, 1=Back, 2=Snapshot, 3=Select] + # st: Press type [S=Short, B=Bold, L=Long] + # returns True if action fully handled or False if parent action should be triggered + def switch(self, i, st): + if i == 0 and st == "S": + self.show_menu() + return True + elif i == 1: + if st == 'B': + self.set_edit_mode(EDIT_MODE_HISTORY) + return True + elif i == 2: + if st == 'S': + self.cuia_toggle_play() + return True + elif st == 'B': + self.cuia_toggle_record() + return True + elif st == "P": + return False + # ALT mode => Use F1-F4 as copy/paste buttons + elif self.alt_mode and self.switch_i_clipboard is not None and i in self.switch_i_clipboard: + index = self.switch_i_clipboard.index(i) + if st == "S": + self.paste_pattern(index) + return True + elif st == "B": + self.copy_pattern(index) + return True + return False + + def cuia_v5_zynpot_switch(self, params): + i = params[0] + t = params[1].upper() + if i == 0: + if t == 'S' or t == 'B': + self.zyngui.toggle_pated() + return True + elif i == 1: + if t == 'S' or t == 'B': + self.reset_grid_zoom() + return True + elif i == 2: + if t == 'S': + if self.edit_mode == EDIT_MODE_BLOCK: + if not self.block_copied: + self.copy_block(cut=True) + return True + elif self.edit_mode == EDIT_MODE_NONE: + self.set_edit_mode(EDIT_MODE_BLOCK) + return True + elif t == 'B': + if self.param_editor_zctrl: + self.disable_param_editor() + else: + self.menu_cb("Length", "Length") + return True + return False + + # Function to handle BACK button + def back_action(self): + if self.edit_mode == EDIT_MODE_NONE: + self.zynseq.libseq.updateSequenceInfo() + return super().back_action() + elif self.edit_mode == EDIT_MODE_BLOCK: + self.end_select_block() + return True + else: + self.set_edit_mode(EDIT_MODE_NONE) + return True + + # CUIA Actions + + # Function to handle CUIA ARROW_RIGHT + def arrow_right(self): + if not self.param_editor_zctrl and ((self.alt_mode and self.edit_mode in (EDIT_MODE_NONE, EDIT_MODE_BLOCK)) or self.edit_mode == EDIT_MODE_HISTORY): + self.redo_pattern() + else: + self.zynpot_cb(self.ctrl_order[3], 1) + + # Function to handle CUIA ARROW_LEFT + def arrow_left(self): + if not self.param_editor_zctrl and ((self.alt_mode and self.edit_mode in (EDIT_MODE_NONE, EDIT_MODE_BLOCK)) or self.edit_mode == EDIT_MODE_HISTORY): + self.undo_pattern() + else: + self.zynpot_cb(self.ctrl_order[3], -1) + + # Function to handle CUIA ARROW_UP + def arrow_up(self): + if self.param_editor_zctrl: + self.zynpot_cb(self.ctrl_order[3], 1) + elif self.edit_mode: + self.zynpot_cb(self.ctrl_order[2], 1) + elif self.alt_mode: + self.redo_pattern_all() + else: + self.zynpot_cb(self.ctrl_order[2], -1) + + # Function to handle CUIA ARROW_DOWN + def arrow_down(self): + if self.param_editor_zctrl: + self.zynpot_cb(self.ctrl_order[3], -1) + elif self.edit_mode: + self.zynpot_cb(self.ctrl_order[2], -1) + elif self.alt_mode: + self.undo_pattern_all() + else: + self.zynpot_cb(self.ctrl_order[2], 1) + + def start_playback(self): + # Set to start of pattern - work around for timebase issue in library. + self.zynseq.libseq.setSequencePlayPosition(self.phrase, self.sequence, 0) + self.zynseq.libseq.setPlayState(self.zynseq.scene, self.phrase, self.sequence, zynseq.SEQ_STARTING) + + def stop_playback(self): + self.zynseq.libseq.setPlayState(self.zynseq.scene, self.phrase, self.sequence, zynseq.SEQ_STOPPED) + + def toggle_playback(self): + if self.zynseq.libseq.getPlayState(self.zynseq.scene, self.phrase, self.sequence) == zynseq.SEQ_STOPPED: + self.start_playback() + else: + self.stop_playback() + + def get_playback_status(self): + return self.zynseq.libseq.getPlayState(self.zynseq.scene, self.phrase, self.sequence) + + def status_short_touch_action(self): + self.toggle_playback() + + # ------------------------------------------------------------------------- + # CUIA & LEDs methods + # ------------------------------------------------------------------------- + + def get_alt_mode(self): + return self.alt_mode + + def cuia_toggle_alt_mode(self, params=None): + self.alt_mode = not self.alt_mode + return True + + def cuia_toggle_record(self, params=None): + self.toggle_midi_record() + return True + + def cuia_stop(self, params=None): + self.stop_playback() + return True + + def cuia_toggle_play(self, params=None): + self.toggle_playback() + return True + + def update_wsleds(self, leds): + wsl = self.zyngui.wsleds + + if self.alt_mode: + # ALT button + wsl.set_led(leds[0], wsl.wscolor_active2) + + # Copy/paste buttons + for i, wsli in enumerate(self.wsleds_i_clipboard): + if self.clipboard[i] is not None: + if self.clipboard[i][2] == self.pattern: + wsl.blink(leds[wsli], wsl.wscolor_red) + else: + wsl.blink(leds[wsli], wsl.wscolor_active2) + else: + wsl.set_led(leds[wsli], wsl.wscolor_active2) + # REC button: + if self.zynseq.libseq.isMidiRecord(): + wsl.set_led(leds[1], wsl.wscolor_red) + # BACK button + wsl.set_led(leds[8], wsl.wscolor_active2) + else: + wsl.set_led(leds[1], wsl.wscolor_active2) + # STOP button + wsl.set_led(leds[2], wsl.wscolor_active2) + # PLAY button: + pb_status = self.zyngui.screens['pattern_editor'].get_playback_status() + if pb_status == zynseq.SEQ_PLAYING: + wsl.set_led(leds[3], wsl.wscolor_green) + elif pb_status == zynseq.SEQ_STARTING: + wsl.set_led(leds[3], wsl.wscolor_yellow) + elif pb_status in (zynseq.SEQ_STOPPING, zynseq.SEQ_STOPPING_SYNC): + wsl.set_led(leds[3], wsl.wscolor_red) + elif pb_status == zynseq.SEQ_STOPPED: + wsl.set_led(leds[3], wsl.wscolor_active2) + # Arrow buttons + if not self.param_editor_zctrl and ((self.alt_mode and self.edit_mode in (EDIT_MODE_NONE, EDIT_MODE_BLOCK)) or self.edit_mode == EDIT_MODE_HISTORY): + wsl.set_led(leds[4], wsl.wscolor_active2) + wsl.set_led(leds[5], wsl.wscolor_active2) + wsl.set_led(leds[6], wsl.wscolor_active2) + wsl.set_led(leds[7], wsl.wscolor_active2) + +# ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_gui_pated_cc.py b/zyngui/zynthian_gui_pated_cc.py new file mode 100644 index 000000000..3505e11eb --- /dev/null +++ b/zyngui/zynthian_gui_pated_cc.py @@ -0,0 +1,432 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian GUI +# +# Zynthian GUI Step-Sequencer Pattern Editor Base Class +# +# Copyright (C) 2015-2025 Fernando Moyano +# Brian Walton +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import os +import tkinter +import logging +import tkinter.font as tkfont + +# Zynthian specific modules +from zynlibs.zynseq import zynseq +from zynlibs.zynsmf import zynsmf +from zyngui import zynthian_gui_config +from zyngui.zynthian_gui_pated_base import * +from zyngui.zynthian_gui_base import zynthian_gui_base + +# ------------------------------------------------------------------------------ +# Zynthian Step-Sequencer Pattern CC Editor GUI Class +# ------------------------------------------------------------------------------ + + +class zynthian_gui_pated_cc(zynthian_gui_pated_base): + + DEFAULT_VIEW_STEPS = 16 + DEFAULT_VIEW_ROWS = 128 + + # Function to initialise class + def __init__(self): + self.row0 = 8 + self.cc_num = 64 + self.interpolateCC = True + self.marker_width = 5 + super().__init__() + self.marker_width = self.width // 150 + + # Function to get name of this view + def get_name(self): + return "pattern cc editor" + + def get_title(self): + title = f"{super().get_title()}: CC{self.cc_num}" + # Get CC title from zynstep mapped zctrl + midi_chan = self.zynseq.libseq.getChannel(self.zynseq.scene, self.phrase, self.sequence, 0) + zctrl = self.zyngui.chain_manager.get_zynstep_mapped_zctrl(midi_chan, self.cc_num) + if zctrl: + title = f"{title} ({zctrl.name})" + return title + + def set_edit_mode(self, mode): + # Currently EDIT modes disabled in CC editor + if mode in (EDIT_MODE_SINGLE, EDIT_MODE_MULTI): + mode = EDIT_MODE_NONE + super().set_edit_mode(mode) + + def set_edit_title(self): + super().set_edit_title() + + # ------------------------------------------------------------------------- + # Pattern menu + # ------------------------------------------------------------------------- + + def get_menu_options(self): + menu_options = super().get_menu_options() + # Pattern Options + options = {} + if self.interpolateCC: + options[f"\u2612 Interpolate CC{self.cc_num} values"] = 'Interpolate CC OFF' + else: + options[f"\u2610 Interpolate CC{self.cc_num} values"] = 'Interpolate CC ON' + menu_options['PATTERN'].update(options) + # Pattern Edit + options = {} + options[f"Clear pattern CC{self.cc_num}"] = 'Clear pattern CC' + menu_options['EDIT'].update(options) + return menu_options + + def menu_cb(self, option, params): + self.save_last_menu_option() + match params: + case 'Interpolate CC OFF': + self.zynseq.libseq.setInterpolateCC(self.cc_num, False) + self.interpolateCC = False + self.redraw_pending = 2 + case 'Interpolate CC ON': + self.zynseq.libseq.setInterpolateCC(self.cc_num, True) + self.interpolateCC = True + self.redraw_pending = 2 + case 'Clear pattern CC': + self.clear_pattern_cc() + case _: + super().menu_cb(option, params) + + def send_controller_value(self, zctrl): + super().send_controller_value(zctrl) + + # ------------------------------------------------------------------------- + # Pattern management + # ------------------------------------------------------------------------- + + # Function to load new pattern + # index: Pattern index + def load_pattern(self, index): + super().load_pattern(index) + self.interpolateCC = self.zynseq.libseq.getInterpolateCC(self.cc_num) + + # Function to clear CC events on pattern + def clear_pattern_cc(self, params=None): + self.zyngui.show_confirm(f"Clear CC{self.cc_num} in pattern {self.pattern}?", self.do_clear_pattern_cc) + + # Function to actually clear CC events + def do_clear_pattern_cc(self, params=None): + self.save_pattern_snapshot(now=True, force=False) + self.zynseq.libseq.clearControl(self.cc_num) + self.save_pattern_snapshot(now=True, force=True) + self.select_cell() + + # ------------------------------------------------------------------------- + # Controller callback + # ------------------------------------------------------------------------- + + def send_controller_value(self, zctrl): + super().send_controller_value(zctrl) + + # ------------------------------------------------------------------------- + # Touch event management + # ------------------------------------------------------------------------- + + # Function to handle grid mouse down + # event: Mouse event + def on_grid_press(self, event): + if self.param_editor_zctrl: + self.disable_param_editor() + + # Get cell coordinates + row = int((self.total_height - self.grid_canvas.canvasy(event.y)) / self.row_height) + step = int(self.grid_canvas.canvasx(event.x) / self.step_width) + + # If there is no CC point, add a new CC point + val = self.get_cc_value(step) + if val is None: + self.zynseq.libseq.addControl(step, self.cc_num, row, row, 1, 0) + # else, change CC value + else: + self.zynseq.libseq.setControlValue(step, self.cc_num, row, row) + + # Select the cell + self.select_cell(step, row) + + # Start drag state variables + self.swiping = False + self.grid_drag_start = event + self.grid_drag_count = 0 + self.swipe_step_speed = 0 + self.swipe_row_speed = 0 + self.swipe_step_dir = 0 + self.swipe_row_dir = 0 + + # Function to handle grid mouse drag + # event: Mouse event + def on_grid_drag(self, event): + # Get cell coordinates + row = int((self.total_height - self.grid_canvas.canvasy(event.y)) / self.row_height) + step = int(self.grid_canvas.canvasx(event.x) / self.step_width) + + # If there is no CC point, add a new CC point + val = self.get_cc_value(step) + if val is None: + self.zynseq.libseq.addControl(step, self.cc_num, row, row, 1, 0) + # else, change CC value + else: + self.zynseq.libseq.setControlValue(step, self.cc_num, row, row) + + # Select the cell + self.select_cell(step, row) + + # Function to handle grid mouse release + # event: Mouse event + def on_grid_release(self, event): + pass + + def on_gesture(self, gtype, value): + pass + + # ------------------------------------------------------------------------- + # Geometry management + # ------------------------------------------------------------------------- + + def get_pianoroll_num_cells(self): + return 128 + + def calculate_geometry_limits(self): + # Row height limits + self.max_row_height = self.grid_height // 128 + self.min_row_height = self.grid_height // 128 + + # Step width limits + self.max_step_width = self.grid_width // 16 + self.min_step_width = self.grid_width // 128 + try: + self.min_step_width = max(self.min_step_width, self.grid_width // self.n_steps) + except: + pass + + # Function to get cell coordinates + # col: Column number (step) + # row: Row number (keymap index) + # duration: Duration of cell in steps + # offset: Factor to offset start of note + # return: Coordinates required to draw cell + def get_cell(self, col, row, duration, offset, height=1): + x1 = int((col + offset) * self.step_width) + 1 + y1 = self.grid_height - (self.row0 + row + 1) * self.row_height + 1 + x2 = x1 + int(self.step_width * duration) - 1 + y2 = y1 + height * self.row_height - 1 + return [x1, y1, x2, y2] + + def get_cc_value(self, step): + val = self.zynseq.libseq.getControlValue(step, self.cc_num) + if val <= 127: + return val + else: + return None + + # ------------------------------------------------------------------------- + # Drawing functions + # ------------------------------------------------------------------------- + + def redraw_grid_pending(self): + super().redraw_grid_pending() + + if self.redraw_pending > 1: + if self.redraw_pending > 3: + self.piano_roll.delete("valtick") + self.grid_canvas.delete("gridhline") + grid_font = tkfont.Font(family=zynthian_gui_config.font_topbar[0], size=self.row_height * 4) + for row in range(0, 129): + if row % 8 == 0: + ypos = self.grid_height - (self.row0 + row) * self.row_height + self.piano_roll.create_text(self.piano_roll_width - 2, ypos - 0.5 * self.row_height, text=str(row), font=grid_font, anchor="e", fill="white", tags="valtick") + self.grid_canvas.create_line(0, ypos, self.total_width, ypos, fill=GRID_LINE_WEAK, tags=("gridhline")) + + self.grid_canvas.delete("ccevent") + for step in range(0, self.n_steps + 1): + val = self.get_cc_value(step) + if val is not None: + self.draw_cell(step, val) + + # Function to draw pianoroll content + def draw_pianoroll(self): + #self.piano_roll.delete(tkinter.ALL) + pass + + # Function to draw a grid cell + # step: Step (column) index + # row: Index of row + def draw_cell(self, step, row): + duration = self.zynseq.libseq.getControlDuration(step, self.cc_num) + offset = self.zynseq.libseq.getControlOffset(step, self.cc_num) + val2 = self.zynseq.libseq.getControlValueEnd(step, self.cc_num) + coord = self.get_cell(step, row, duration, offset, row - val2) + self.grid_canvas.create_line(coord, fill="white", width=2, tags=("ccevent", f"step{step}")) + if self.interpolateCC: + coord[2] = coord[0] + self.marker_width + coord[3] = coord[1] + self.marker_width + coord[0] -= self.marker_width + coord[1] -= self.marker_width + self.grid_canvas.create_rectangle(coord, fill="white", width=0, tags=("ccevent", f"step{step}")) + + # Function to update selectedCell + # step: Step (column) of selected cell (Optional - default to reselect current column) + # row: Index of keymap to select (Optional - default to reselect current row). + # Maybe outside visible range to scroll display + def select_cell(self, step=None, row=None): + # Check column boundaries + if step is None: + step = self.selected_cell[0] + if step < 0: + step = 0 + elif step >= self.n_steps: + step = self.n_steps - 1 + else: + step = int(step) + # Check step offset + if step >= self.step_offset + int(self.view_steps): + # Step is off right of display + self.set_step_offset(step - int(self.view_steps) + 1) + elif step < self.step_offset: + # Step is off left of display + self.set_step_offset(step) + # Force row value selection if event in this step + val = self.get_cc_value(step) + if val is not None: + offset = self.zynseq.libseq.getControlOffset(step, self.cc_num) + row = val + else: + offset = 0 + # Check row boundaries + if row is None: + row = self.selected_cell[1] + if row < 0: + row = 0 + elif row >= self.get_pianoroll_num_cells(): + row = self.get_pianoroll_num_cells() - 1 + else: + row = int(row) + self.selected_cell = [step, row] + + if self.edit_mode == EDIT_MODE_BLOCK: + return + + # Hide selected block + self.hide_selected_block() + # Position selector cell-frame + coord = self.get_cell(step, row, 1, offset) + if self.interpolateCC: + sw = self.marker_width + 1 + coord[2] = coord[0] + sw + coord[3] = coord[1] + sw + coord[0] -= sw + coord[1] -= sw + if not self.rect_selected_cell: + self.rect_selected_cell = self.grid_canvas.create_rectangle(coord, fill=SELECT_BORDER, outline=SELECT_BORDER, + width=self.select_thickness, tags="selected_cell") + else: + self.grid_canvas.coords(self.rect_selected_cell, coord) + self.grid_canvas.tag_raise(self.rect_selected_cell) + + + # --------------------------------------------------------------- + # Block edit functionality => Copy/paste block + # --------------------------------------------------------------- + + def copy_block(self, cut=False): + self.end_select_block() + + def move_block(self, dstep, drow): + pass + + def paste_block(self): + pass + + # ------------------------------------------------------------------------- + # Event management + # ------------------------------------------------------------------------- + + # Function to toggle event + # step: step number (column) + # row: row index + # Returns: Event number if event added else None + def toggle_event(self, step, row): + val = self.get_cc_value(step) + if val is None: + self.zynseq.libseq.addControl(step, self.cc_num, row, row, 1, 0) + return self.cc_num + else: + self.zynseq.libseq.removeControl(step, self.cc_num) + return None + + # Function to remove an event + # step: step number (column) + # row: row index + def remove_event(self, step, row): + val = self.get_cc_value(step) + if val is not None: + self.zynseq.libseq.removeControl(step, self.cc_num) + + # Function to refresh status + def refresh_status(self): + super().refresh_status() + + # Function to handle zynpots value change + # i: Zynpot index [0..n] + # dval: Current value of zyncoder + def zynpot_cb(self, i, dval): + #logging.debug(f"DVAL {i} => {dval}") + if zynthian_gui_base.zynpot_cb(self, i, dval): + return True + + if i == self.ctrl_order[0]: + if self.edit_mode == EDIT_MODE_NONE: + self.cc_num += dval + if self.cc_num < 1: + self.cc_num = 1 + elif self.cc_num > 127: + self.cc_num = 127 + self.set_title() + self.interpolateCC = self.zynseq.libseq.getInterpolateCC(self.cc_num) + self.redraw_pending = 2 + return True + elif i == self.ctrl_order[2]: + if self.edit_mode == EDIT_MODE_NONE: + step = self.selected_cell[0] + val = self.get_cc_value(step) + # Change value for existing CC event + if val is not None: + newval = val - dval + if newval > 127: + newval = 127 + if newval < 0: + newval = 0 + if newval != val: + self.zynseq.libseq.setControlValue(step, self.cc_num, newval, newval) + # Select cell + self.select_cell(step, self.selected_cell[1] + dval) + return True + + if super().zynpot_cb(i, dval): + return True + +# ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_gui_pated_notes.py b/zyngui/zynthian_gui_pated_notes.py new file mode 100644 index 000000000..a77b01f40 --- /dev/null +++ b/zyngui/zynthian_gui_pated_notes.py @@ -0,0 +1,1745 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian GUI +# +# Zynthian GUI Step-Sequencer Pattern Note Editor Class +# +# Copyright (C) 2015-2025 Fernando Moyano +# Brian Walton +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import os +import json +import tkinter +import logging +from math import ceil +from queue import Queue +from xml.dom import minidom +import tkinter.font as tkfont + +# Zynthian specific modules +from zynlibs.zynseq import zynseq +from zyngui import zynthian_gui_config +from zyngui.multitouch import MultitouchTypes +from zyngui.zynthian_gui_pated_base import * +from zyngui.zynthian_gui_base import zynthian_gui_base + +# ------------------------------------------------------------------------------ + +# Event draw modes +EVENT_DRAW_NORMAL = 0 # Draw as normal event +EVENT_DRAW_CP = 1 # Draw as copied into the copy/paste buffer +EVENT_DRAW_SEL = 2 # Draw as selected event + +EDIT_PARAM_DUR = 0 # Edit event duration +EDIT_PARAM_VEL = 1 # Edit event velocity +EDIT_PARAM_OFFSET = 2 # Edit event offset +EDIT_PARAM_STUT_SPD = 3 # Edit note stutter speed +EDIT_PARAM_STUT_VFX = 4 # Edit note stutter velocity FX (fade) +EDIT_PARAM_STUT_RMP = 5 # Edit note stutter speed ramp +EDIT_PARAM_PLAY_FREQ = 6 # Edit note play frequency +EDIT_PARAM_PLAY_CHANCE = 7 # Edit note play chance +EDIT_PARAM_STUT_FREQ = 8 # Edit note stutter frequency +EDIT_PARAM_STUT_CHANCE = 9 # Edit note stutter chance +EDIT_PARAM_LAST = 9 # Index of last parameter + +STUT_VFX_OPTIONS = ( + "FLAT", + "FADE-IN", + "FADE-OUT" +) +STUT_RMP_OPTIONS = ( + "NONE", + "SPEED-UP", + "SPEED-DOWN" +) +PLAY_FREQ_OPTIONS = ( + "NEVER", + "ALWAYS", + "PLAY/2", + "SKIP/2", + "PLAY/3", + "SKIP/3", + "PLAY/4", + "SKIP/4", + "PLAY/5", + "SKIP/5", + "PLAY/6", + "SKIP/6", + "PLAY/7", + "SKIP/7", + "PLAY/8", + "SKIP/8" +) +STUT_FREQ_OPTIONS = ( + "NEVER", + "ALWAYS", + "STUT/2", + "SKIP/2", + "STUT/3", + "SKIP/3", + "STUT/4", + "SKIP/4", + "STUT/5", + "SKIP/5", + "STUT/6", + "SKIP/6", + "STUT/7", + "SKIP/7", + "STUT/8", + "SKIP/8" +) + +NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] +SCALES = { + "major": [0, 2, 4, 5, 7, 9, 11, 12, 14, 16, 17, 19, 21, 23], + "minor": [0, 2, 3, 5, 7, 8, 10, 12, 14, 15, 17, 19, 20, 22] +} + +CHORD_MODES = [ + "Single note", + "Chord", + "Diatonic triads, major key", + "Diatonic 7ths, major key", + "Diatonic triads, minor key", + "Diatonic 7ths, minor key" +] + +CHORDS = [ + # Triads + ["Major", [0, 4, 7]], + ["Minor", [0, 3, 7]], + ["Diminished", [0, 3, 6]], + ["Augmented", [0, 4, 8]], + # Seventh chords + ["Major 7th", [0, 4, 7, 11]], # (maj7) + ["Minor 7th", [0, 3, 7, 10]], # (m7) + ["Dominant 7th", [0, 4, 7, 10]], # (7) + ["Half-Diminished 7th", [0, 3, 6, 10]], # (m7♭5) + ["Diminished 7th", [0, 3, 6, 9]], # (dim7) + ["Minor-Major 7th", [0, 3, 7, 11]], # (m(maj7)) + ["Augmented Major 7th", [0, 4, 8, 11]], # (+maj7) + ["Augmented 7th", [0, 4, 8, 10]], # (+7) + # Extended chords + ["Major 9th", [0, 4, 7, 11, 14]], # (maj9) + ["Dominant 9th", [0, 4, 7, 10, 14]], # (9) + ["Minor 9th", [0, 3, 7, 10, 14]], # (m9) + ["Minor-Major 9th", [0, 3, 7, 11, 14]], # (m(maj9)) + ["Dominant 11th", [0, 4, 7, 10, 14, 17]], # (11) + ["Minor 11th", [0, 3, 7, 10, 14, 17]], # (m11) + ["Dominant 13th", [0, 4, 7, 10, 14, 17, 21]], # (13) + ["Minor 13th", [0, 3, 7, 10, 14, 17, 21]], # (m13) + # Suspended chords + ["Suspended 2nd", [0, 2, 7]], # (sus2) + ["Suspended 4th", [0, 5, 7]], # (sus4) + ["7sus4", [0, 5, 7, 10]], + # Add chords + ["Add9", [0, 4, 7, 14]], + ["Minor Add9", [0, 3, 7, 14]], # (madd9) + # 6th chords + ["Major 6th", [0, 4, 7, 9]], # (6) + ["Minor 6th", [0, 3, 7, 9]], # (m6) + # Altered 7th chords + ["Half-Diminished Dominant", [0, 4, 6, 10]] # (7♭5) +] + +# ------------------------------------------------------------------------------ +# Zynthian Step-Sequencer Pattern Note Editor GUI Class +# ------------------------------------------------------------------------------ + + +class zynthian_gui_pated_notes(zynthian_gui_pated_base): + + DEFAULT_VIEW_STEPS = 16 + DEFAULT_VIEW_ROWS = 16 + + # Function to initialise class + def __init__(self): + self.edit_param = EDIT_PARAM_DUR # Parameter to adjust in parameter edit mode + + # Note-entry values + self.duration = 1.0 + self.velocity = 100 + self.offset = 0.0 + self.stut_speed = 0 + self.stut_velfx = 0 + self.stut_ramp = 0 + self.play_freq = 1 + self.play_chance = 1.0 + self.stut_freq = 1 + self.stut_chance = 1.0 + + self.keymap = [] # Array of {"note":MIDI_NOTE_NUMBER, "name":"key name","colour":"key colour"} name and colour are optional + self.keymap_offset = 60 # MIDI note number of bottom row in grid + self.reload_keymap = False # True when keymap needs reloading + self.chord_mode = 0 # Chord entry mode. 0 for single note entry + self.chord_type = 0 # Chord type. Index of CHORD + self.diatonic_scale_tonic = 0 # Tonic of diatonic scale used for chords + self.rows_pending = Queue() + + # Touch control variables + self.drag_start_velocity = None # Velocity value at start of drag + self.drag_note = False # True if dragging note in grid + self.drag_velocity = False # True indicates drag will adjust velocity + self.drag_duration = False # True indicates drag will adjust duration + + super().__init__() + + # Function to get name of this view + def get_name(self): + return "pattern editor" + + def get_title(self): + title = super().get_title() + if self.chord_mode: + return f"{title}: Chords" + else: + return f"{title}: Notes" + + def get_evnum_from_row(self, row): + try: + return self.keymap[row]["note"] + except: + return None + + def get_row_from_evnum(self, num): + for row in range(0, len(self.keymap)): + if self.keymap[row]["note"] == num: + return row + return None + + get_note_from_row = get_evnum_from_row + get_row_from_note = get_row_from_evnum + + def get_diatonic_chord(self, trigger_note): + chord = [] + match self.chord_mode: + case 2 | 3: + scale_template = SCALES["major"] + case 4 | 5: + scale_template = SCALES["minor"] + case _: + return [] + if self.chord_mode in [3, 5]: + chord_len = 4 + else: + chord_len = 3 + scale_offset = trigger_note % 12 + self.diatonic_scale_tonic + if scale_offset not in scale_template: + return [] # Trigger note not in diatonic scale + note_offset = scale_template.index(scale_offset) + for i in range(chord_len): + chord.append(scale_template[note_offset + 2 * i] - scale_template[note_offset]) + return chord + + def play_note(self, note): + if self.zynseq.libseq.getPlayState(self.zynseq.scene, self.phrase, self.sequence) == zynseq.SEQ_STOPPED: + self.zynseq.libseq.playNote(note, self.velocity, self.channel, int(200 * self.duration)) + + # ------------------------------------------------------------------------- + # Pattern menu + # ------------------------------------------------------------------------- + + def get_menu_options(self): + menu_options = super().get_menu_options() + # Pattern Options + options = {} + options[f"Velocity Humanization ({int(self.zynseq.libseq.getHumanVelo())})"] = 'Velocity Humanization' + options[f"Note Play Chance ({round(100 * self.zynseq.libseq.getPlayChance())}%)"] = 'Note Play Chance' + scales = self.get_scales() + options[f"Scale ({scales[self.zynseq.libseq.getScale()]})"] = 'Scale' + options[f"Tonic ({NOTE_NAMES[self.zynseq.libseq.getTonic()]})"] = 'Tonic' + menu_options['PATTERN'].update(options) + # Pattern Edit + options = {} + note = self.zynseq.libseq.getInputRest() + if note < 128: + options[f"Rest-step note ({NOTE_NAMES[note % 12]}{note // 12 - 1})"] = 'Rest note' + else: + options["Rest note (None)"] = 'Rest note' + options[f"Chord mode ({CHORD_MODES[self.chord_mode]})"] = 'Chord mode' + if self.chord_mode == 1: + options[f"Chord type ({CHORDS[self.chord_type][0]})"] = 'Chord type' + elif self.chord_mode >= 2: + options[f"Diatonic key ({NOTE_NAMES[self.diatonic_scale_tonic]})"] = 'Chord type' + options['Transpose pattern'] = 'Transpose pattern' + options.update(menu_options['EDIT']) + options['Clear pattern notes'] = 'Clear pattern notes' + menu_options['EDIT'] = options + return menu_options + + def menu_cb(self, option, params): + self.save_last_menu_option() + match params: + case 'Velocity Humanization': + self.enable_param_editor(self, 'human_vel', {'name': 'Velocity Humanization', 'value_min': 0, + 'value_max': 100, 'value_default': 0, + 'value': int(self.zynseq.libseq.getHumanVelo())}) + + case 'Note Play Chance': + self.enable_param_editor(self, 'play_chance', {'name': 'Note Play Chance', 'value_min': 0, + 'value_max': 100, 'value_default': 100, + 'value': round(100.0 * self.zynseq.libseq.getPlayChance())}) + + case 'Scale': + self.enable_param_editor(self, 'scale', {'name': 'Scale', 'labels': self.get_scales(), + 'value': self.zynseq.libseq.getScale()}) + case 'Tonic': + self.enable_param_editor(self, 'tonic', {'name': 'Tonic', 'labels': NOTE_NAMES, + 'value': self.zynseq.libseq.getTonic()}) + case 'Rest note': + labels = ['None'] + for note in range(128): + labels.append("{}{}".format(NOTE_NAMES[note % 12], note // 12 - 1)) + value = self.zynseq.libseq.getInputRest() + 1 + if value > 128: + value = 0 + self.enable_param_editor(self, 'rest', {'name': 'Rest-step note', 'labels': labels, 'value': value}) + case 'Chord mode': + self.enable_param_editor(self, 'chord_mode', {'name': 'Chord mode', 'labels': CHORD_MODES, + 'value': self.chord_mode}) + case 'Chord type': + if self.chord_mode == 1: + self.enable_param_editor(self, 'chord_type', {'name': 'Chord type', + 'labels': [item[0] for item in CHORDS], + 'value': self.chord_type}) + elif self.chord_mode >= 2: + self.enable_param_editor(self, 'diatonic_key', {'name': 'Diatonic key', 'labels': NOTE_NAMES, + 'value': self.diatonic_scale_tonic}) + case 'Transpose pattern': + self.enable_param_editor(self, 'transpose', {'name': 'Transpose', 'value_min': -1, 'value_max': 1, + 'labels': ['down', 'down/up', 'up'], 'value': 0}) + case 'Clear pattern notes': + self.clear_pattern_notes() + case _: + super().menu_cb(option, params) + + def send_controller_value(self, zctrl): + match zctrl.symbol: + case 'human_vel': + self.zynseq.libseq.setHumanVelo(1.0 * zctrl.value) + case 'play_chance': + self.zynseq.libseq.setPlayChance(zctrl.value / 100.0) + case 'transpose': + self.transpose(zctrl.value) + zctrl.set_value(0) + case 'scale': + self.set_scale(zctrl.value) + case 'tonic': + self.set_tonic(zctrl.value) + case 'rest': + if zctrl.value == 0: + self.zynseq.libseq.setInputRest(128) + else: + self.zynseq.libseq.setInputRest(zctrl.value - 1) + case 'chord_mode': + self.chord_mode = zctrl.value + case 'chord_type': + self.chord_type = zctrl.value + case 'diatonic_key': + self.diatonic_scale_tonic = zctrl.value + case _: + super().send_controller_value(zctrl) + + # Function to transpose pattern + def transpose(self, offset): + if offset != 0: + self.save_pattern_snapshot(now=True, force=False) + if self.zynseq.libseq.getScale(): + # Change to chromatic scale to transpose + self.zynseq.libseq.setScale(0) + self.load_keymap() + self.zynseq.libseq.transpose(offset) + self.save_pattern_snapshot(now=True, force=True) + self.set_keymap_offset(self.keymap_offset + offset) + self.selected_cell[1] += int(offset) + self.redraw_pending = 3 + self.select_cell() + + # ------------------------------------------------------------------------- + # Pattern management + # ------------------------------------------------------------------------- + + # Function to load new pattern + # index: Pattern index + def load_pattern(self, index): + # Load requested pattern + self.zynseq.libseq.selectPattern(index) + self.pattern = index + self.selected_events = None + self.block_copied = None + n_steps = self.zynseq.libseq.getSteps() + n_steps_beat = self.zynseq.libseq.getStepsPerBeat() + keymap_len = len(self.keymap) + self.load_keymap() + if n_steps != self.n_steps or n_steps_beat != self.n_steps_beat or len(self.keymap) != keymap_len: + self.n_steps = n_steps + self.n_steps_beat = n_steps_beat + self.step_offset = 0 + self.update_geometry() + if self.duration > n_steps: + self.duration = 1 + keymap_len = len(self.keymap) + self.redraw_pending = 4 + else: + self.redraw_pending = 3 + + # Vertical position => keymap_offset + if keymap_len > self.view_rows: + self.keymap_offset = int(self.zynseq.libseq.getRefNote()) + else: + self.keymap_offset = 0 + self.zynseq.libseq.setRefNote(0) + self.set_keymap_offset() + + # Selected cell + if self.selected_cell[0] >= n_steps: + self.selected_cell[0] = int(n_steps) - 1 + self.selected_cell[1] = int(self.keymap_offset + self.view_rows / 2) + + # Draw grid and adjust zoom + self.draw_grid() + self.set_grid_zoom(self.zynseq.libseq.getPatternZoom()) + + self.play_canvas.coords("playCursor", 1, 0, 1 + self.step_width, PLAYHEAD_HEIGHT) + self.set_title() + if not self.seq_info: + # Populate editor sequence + self.zynseq.libseq.clearSequence(self.zynseq.scene, self.phrase, self.sequence) + self.zynseq.libseq.addPattern(self.zynseq.scene, self.phrase, self.sequence, 0, 0, index, True) + self.zynseq.libseq.setChannel(self.zynseq.scene, self.phrase, self.sequence, 0, self.channel) + + # Function to clear Note events on pattern + def clear_pattern_notes(self, params=None): + self.zyngui.show_confirm(f"Clear notes in pattern {self.pattern}?", self.do_clear_pattern_notes) + + # Function to actually clear CC events + def do_clear_pattern_notes(self, params=None): + self.save_pattern_snapshot(now=True, force=False) + self.zynseq.libseq.clearNotes() + self.save_pattern_snapshot(now=True, force=True) + self.select_cell() + + # ------------------------------------------------------------------------- + # Scales and keymap + # ------------------------------------------------------------------------- + + # Function to set musical scale + # scale: Index of scale to load + # Returns: name of scale + def set_scale(self, scale): + self.zynseq.libseq.setScale(scale) + self.reload_keymap = True + self.redraw_pending = 3 + + # Function to set tonic (root note) of scale + # tonic: Scale root note + def set_tonic(self, tonic): + self.zynseq.libseq.setTonic(tonic) + self.reload_keymap = True + self.redraw_pending = 3 + + # Function to get list of scales + # returns: List of available scales + def get_scales(self): + # Load scales + data = [] + try: + with open(CONFIG_ROOT + "/scales.json") as json_file: + data = json.load(json_file) + except: + logging.warning("Unable to open scales.json") + res = [] + # Look for a custom keymap, defaults to chromatic + custom_keymap = self.get_custom_keymap() + if custom_keymap: + res.append(f"Custom - {custom_keymap[0]}") + else: + res.append(f"Custom - None") + for scale in data: + res.append(scale['name']) + return res + + # Search for a custom map and return a tuple with [map_name, filepath / engine] + def get_custom_keymap(self): + synth_proc = self.zyngui.chain_manager.get_synth_processor(self.channel) + if synth_proc: + # Ask the synth processor for a keymap + try: + keymap_name = synth_proc.get_keymap_name() + except: + keymap_name = None + if keymap_name: + return [keymap_name, synth_proc] + # else, try to find a midnam file + else: + map_name = None + preset_path = synth_proc.get_presetpath() + try: + with open(CONFIG_ROOT + "/keymaps.json") as json_file: + data = json.load(json_file) + for pat in data: + if pat in preset_path: + map_name = data[pat] + break + if map_name: + keymap_fpath = CONFIG_ROOT + f"/{map_name}.midnam" + if os.path.isfile(keymap_fpath): + return [map_name, keymap_fpath] + else: + logging.warning(f"Keymap file {keymap_fpath} doesn't exist.") + except: + logging.warning("Unable to load keymaps.json") + else: + logging.info(f"MIDI channel {self.channel} has not synth processors.") + + # Function to populate keymap array + # returns Name of scale / map + def load_keymap(self): + self.keymap = [] + + scale = self.zynseq.libseq.getScale() + tonic = self.zynseq.libseq.getTonic() + + # Try to load custom keymap + if scale == 0: + map_info = self.get_custom_keymap() + if map_info: + map_name = map_info[0] + # map_info[1] is the filename of a midnam file + if isinstance(map_info[1], str) and map_info[1].endswith(".midnam"): + keymap_fpath = map_info[1] + logging.info(f"Loading keymap {map_name} for MIDI channel {self.channel}...") + try: + xml = minidom.parse(keymap_fpath) + notes = xml.getElementsByTagName('Note') + for note in notes: + try: + colour = note.attributes['Colour'].value + except: + colour = "white" + self.keymap.append({'note': int(note.attributes['Number'].value), + 'name': note.attributes['Name'].value, + 'colour': colour}) + return map_name + except Exception as e: + logging.error(f"Can't load '{keymap_fpath}' => {e}") + # map[1] is an engine to ask for a custom keymap + else: + try: + self.keymap = map_info[1].get_keymap() + return map_name + except: + pass + + # Not custom map loaded => Setup a scale keymap + + # Setup specific scale + if scale > 1: + try: + with open(CONFIG_ROOT + "/scales.json") as json_file: + data = json.load(json_file) + if scale <= len(data): + scale -= 1 # Offset by -1 because the 0 is used for custom keymap + for octave in range(0, 9): + for offset in data[scale]['scale']: + note = tonic + offset + octave * 12 + if note > 127: + break + self.keymap.append({"note": note, "name": "{}{}".format(NOTE_NAMES[note % 12], note // 12 - 1)}) + return data[scale]['name'] + except Exception as e: + logging.error(f"Can't load 'scales.json' => {e}") + + # Setup chromatic scale + for note in range(0, 128): + new_entry = {"note": note} + key = note % 12 + if key in (1, 3, 6, 8, 10): # Black notes + new_entry.update({"colour": "black"}) + else: + new_entry.update({"colour": "white"}) + if key == 0: # 'C' + new_entry.update({"name": "C{}".format(note // 12 - 1)}) + self.keymap.append(new_entry) + return "Chromatic" + + # ------------------------------------------------------------------------- + # Touch event management + # ------------------------------------------------------------------------- + + # Function to handle pianoroll drag motion + def on_pianoroll_motion(self, event): + offset = super().on_pianoroll_motion(event) + self.set_keymap_offset(self.keymap_offset + offset) + if self.selected_cell[1] < self.keymap_offset: + self.selected_cell[1] = self.keymap_offset + elif self.selected_cell[1] >= self.keymap_offset + int(self.view_rows): + self.selected_cell[1] = self.keymap_offset + int(self.view_rows) - 1 + self.select_cell() + return offset + + def on_pianoroll_release_action(self, event): + # Play note if not drag action + row = int((self.total_height - self.piano_roll.canvasy(event.y)) / self.row_height) + if row < len(self.keymap): + note = self.keymap[row]['note'] + self.play_note(note) + self.pianoroll_note_on(note) + zynthian_gui_config.top.after(200, self.pianoroll_note_off, note) + + + # Function to handle mouse wheel over pianoroll + def on_pianoroll_wheel(self, event): + if event.num == 4: + # Scroll up + if self.keymap_offset + self.view_rows < len(self.keymap): + self.set_keymap_offset(self.keymap_offset + 1) + if self.selected_cell[1] < self.keymap_offset: + self.select_cell(self.selected_cell[0], self.keymap_offset) + else: + # Scroll down + if self.keymap_offset > 0: + self.set_keymap_offset(self.keymap_offset - 1) + if self.selected_cell[1] >= self.keymap_offset + self.view_rows: + self.select_cell(self.selected_cell[0], self.keymap_offset + self.view_rows - 1) + + # Function to handle grid mouse down + # event: Mouse event + def on_grid_press(self, event): + if self.param_editor_zctrl: + self.disable_param_editor() + + # Select cell + row = int((self.total_height - self.grid_canvas.canvasy(event.y)) / self.row_height) + step = int(self.grid_canvas.canvasx(event.x) / self.step_width) + try: + note = self.keymap[row]['note'] + except: + return + start_step = self.zynseq.libseq.getNoteStart(step, note) + if start_step >= 0: + step = start_step + if step < 0 or step >= self.n_steps: + return + self.select_cell(step, row) + + # Start drag state variables + self.swiping = False + self.grid_drag_start = event + self.grid_drag_count = 0 + self.swipe_step_speed = 0 + self.swipe_row_speed = 0 + self.swipe_step_dir = 0 + self.swipe_row_dir = 0 + self.drag_note = False + self.drag_velocity = False + self.drag_duration = False + self.drag_start_step = step + self.drag_start_velocity = self.zynseq.libseq.getNoteVelocity(step, note) + self.drag_start_duration = self.zynseq.libseq.getNoteDuration(step, note) + + # Function to handle grid mouse drag + # event: Mouse event + def on_grid_drag(self, event): + if not self.grid_drag_start: + return + if self.grid_drag_count == 0 and abs(event.x - self.grid_drag_start.x) < 2 or \ + abs(event.y - self.grid_drag_start.y) < 2: + # Avoid interpretting tap as drag (especially on V4 touchscreen) + return + self.grid_drag_count += 1 + + if self.drag_note: + step = self.selected_cell[0] + row = self.selected_cell[1] + note = self.keymap[row]['note'] + sel_duration = self.zynseq.libseq.getNoteDuration(step, note) + sel_velocity = self.zynseq.libseq.getNoteVelocity(step, note) + + if self.drag_start_velocity: + # Selected cell has a note, so we want to adjust its velocity or duration + if not self.drag_velocity and not self.drag_duration and\ + (event.x > (self.drag_start_step + 1) * self.step_width or + event.x < self.drag_start_step * self.step_width): + self.drag_duration = True + if not self.drag_duration and not self.drag_velocity and\ + (event.y > self.grid_drag_start.y + self.row_height / 2 or + event.y < self.grid_drag_start.y - self.row_height / 2): + self.drag_velocity = True + if self.drag_velocity: + value = (self.grid_drag_start.y - event.y) / self.row_height + velocity = int(self.drag_start_velocity + value * self.height / 100) + if 1 <= velocity <= 127: + self.set_velocity_indicator(velocity) + if sel_duration and velocity != sel_velocity: + self.zynseq.libseq.setNoteVelocity(step, note, velocity) + self.draw_cell(step, row) + if self.drag_duration: + duration = int(event.x / self.step_width) - self.drag_start_step + if duration > 0 and duration != sel_duration: + self.add_note_event(step, row, sel_velocity, duration) + else: + # self.duration = duration + pass + else: + # Clicked on empty cell so want to add a new note by dragging towards the desired cell + # x pos of start of event + x1 = self.selected_cell[0] * self.step_width + x2 = x1 + self.step_width # x pos right of event's first cell + # y pos of bottom of selected row + y1 = self.total_height - self.selected_cell[1] * self.row_height + y2 = y1 - self.row_height # y pos of top of selected row + event_x = self.grid_canvas.canvasx(event.x) + event_y = self.grid_canvas.canvasy(event.y) + if event_x < x1: + self.select_cell(self.selected_cell[0] - 1, None) + elif event_x > x2: + self.select_cell(self.selected_cell[0] + 1, None) + elif event_y > y1: + self.select_cell(None, self.selected_cell[1] - 1) + self.play_note(self.keymap[self.selected_cell[1]]["note"]) + elif event_y < y2: + self.select_cell(None, self.selected_cell[1] + 1) + self.play_note(self.keymap[self.selected_cell[1]]["note"]) + else: + step_offset = int(DRAG_SENSIBILITY * (self.grid_drag_start.x - event.x) / self.step_width) + row_offset = int(DRAG_SENSIBILITY * (event.y - self.grid_drag_start.y) / self.row_height) + if step_offset == 0 and row_offset == 0: + if self.grid_drag_count < 2 and (event.time - self.grid_drag_start.time) > 800: + self.drag_note = True + return + self.swiping = True + self.grid_drag_start = event + if step_offset: + self.swipe_step_dir = step_offset + self.set_step_offset(self.step_offset + step_offset) + if row_offset: + self.swipe_row_dir = row_offset + self.set_keymap_offset(self.keymap_offset + row_offset) + if self.selected_cell[1] < self.keymap_offset: + self.selected_cell[1] = self.keymap_offset + elif self.selected_cell[1] >= self.keymap_offset + int(self.view_rows): + self.selected_cell[1] = self.keymap_offset + int(self.view_rows) - 1 + self.select_cell() + + # Function to handle grid mouse release + # event: Mouse event + def on_grid_release(self, event): + # No drag actions + if self.grid_drag_start: + dts = event.time - self.grid_drag_start.time + if self.grid_drag_count == 0: + # Bold click without drag + if dts > 800: + if self.edit_mode == EDIT_MODE_NONE: + self.set_edit_mode(EDIT_MODE_SINGLE) + else: + self.set_edit_mode(EDIT_MODE_MULTI) + # Short click without drag: Add/remove single note/chord + else: + step = self.selected_cell[0] + row = self.selected_cell[1] + self.toggle_event(step, row) + # End drag action + elif self.drag_note: + if not self.drag_start_velocity: + # Drag drop note + step = self.selected_cell[0] + row = self.selected_cell[1] + # note = self.keymap[row]['note'] + self.toggle_event(step, row) + # Swipe + elif self.swiping: + self.swipe_nudge(dts/1000) + + # Reset drag state variables + self.grid_drag_start = None + self.grid_drag_count = 0 + self.drag_note = False + self.drag_velocity = False + self.drag_duration = False + self.drag_start_step = None + self.drag_start_velocity = None + self.drag_start_duration = None + + def on_gesture(self, gtype, value): + if gtype == MultitouchTypes.GESTURE_H_DRAG: + value = int(-0.1 * value) + self.set_step_offset(self.step_offset + value) + self.select_cell() + elif gtype == MultitouchTypes.GESTURE_V_DRAG: + value = int(0.1 * value) + self.set_keymap_offset(self.keymap_offset + value) + if self.selected_cell[1] < self.keymap_offset: + self.selected_cell[1] = self.keymap_offset + elif self.selected_cell[1] >= self.keymap_offset + int(self.view_rows): + self.selected_cell[1] = self.keymap_offset + int(self.view_rows) - 1 + self.select_cell() + elif gtype in (MultitouchTypes.GESTURE_H_PINCH, MultitouchTypes.GESTURE_V_PINCH): + value = int(0.1 * value) + self.set_grid_zoom(self.zoom + value) + + # Update swipe vertical scroll + def swipe_vertical_action(self): + self.keymap_offset += int(self.swipe_row_offset) + self.swipe_row_offset -= int(self.swipe_row_offset) + self.set_keymap_offset(self.keymap_offset) + if self.selected_cell[1] < self.keymap_offset: + self.selected_cell[1] = self.keymap_offset + elif self.selected_cell[1] >= self.keymap_offset + int(self.view_rows): + self.selected_cell[1] = self.keymap_offset + int(self.view_rows) - 1 + + # ------------------------------------------------------------------------- + # Geometry management + # ------------------------------------------------------------------------- + + def calculate_geometry_limits(self): + self.n_rows = len(self.keymap) + super().calculate_geometry_limits() + + # Function to set kaymap offset and move grid view accordingly + # offset: Keymap Offset (note at bottom row) + def set_keymap_offset(self, offset=None): + max_keymap_offset = max(0, len(self.keymap) - self.view_rows) + if offset is not None: + self.keymap_offset = int(offset) + if self.keymap_offset > max_keymap_offset: + self.keymap_offset = int(max_keymap_offset) + elif self.keymap_offset < 0: + self.keymap_offset = 0 + ypos = (self.scroll_height - self.keymap_offset * self.row_height) / self.total_height + self.grid_canvas.yview_moveto(ypos) + self.piano_roll.yview_moveto(ypos) + self.zynseq.libseq.setRefNote(int(self.keymap_offset)) + #logging.debug(f"OFFSET: {self.keymap_offset} (keymap length: {len(self.keymap)})") + #logging.debug(f"GRID Y-SCROLL: {ypos}\n\n") + + # Update grid position + def update_grid_position(self, step_width_changed, row_height_changed): + if step_width_changed: + self.set_step_offset() + if row_height_changed: + self.set_keymap_offset() + self.view_rows = self.grid_height / self.row_height + self.view_steps = self.grid_width / self.step_width + + # Reset grid offset + def reset_grid_offset(self): + self.set_keymap_offset() + self.set_step_offset() + + def set_grid_zoom(self, new_zoom=0): + res = super().set_grid_zoom(new_zoom) + self.zynseq.libseq.setPatternZoom(self.zoom) + return res + + # ------------------------------------------------------------------------- + # Drawing functions + # ------------------------------------------------------------------------- + + # Function to adjust velocity indicator + # velocity: Note velocity to indicate + def set_velocity_indicator(self, velocity): + self.velocity_canvas.coords("velocityIndicator", 0, 0, self.piano_roll_width * velocity / 127, PLAYHEAD_HEIGHT) + + # Draw all note events in pattern + def draw_events(self): + self.zynseq.libseq.isPatternModified() + self.grid_canvas.delete("pat") + evdata = zynseq.event_data() + index = 0 + while True: + res = self.zynseq.libseq.getEventDataAt(index, evdata) + if res < 0: + break + #logging.debug(f"DRAWING EVENT AT {index} => {evdata.position}, {evdata.command}") + if evdata.command == zynseq.MIDI_NOTE_ON: + if self.selected_events and index in self.selected_events: + self.draw_event(evdata, EVENT_DRAW_SEL) + else: + self.draw_event(evdata, EVENT_DRAW_NORMAL) + index += 1 + + # Draw all note events in the copy/paste buffer + def draw_cp_events(self): + self.grid_canvas.delete("cp") + if self.block_copied: + evdata = zynseq.event_data() + index = 0 + while True: + res = self.zynseq.libseq.getBufferEventDataAt(index, evdata) + if res < 0: + break + #logging.debug(f"DRAWING CP EVENT AT {index} => {evdata.position}, {evdata.command}") + if evdata.command == zynseq.MIDI_NOTE_ON: + #evdata.position += self.block_dstep + evdata.val1_start += self.block_drow + #if 0 <= evdata.position < self.n_steps and 0 <= evdata.val1_start <= 127: + # Horizontal "circular" displaying + if 0 <= evdata.val1_start <= 127: + pos = evdata.position + self.block_dstep + if pos >= self.n_steps: + pos -= self.n_steps + elif pos < 0: + pos += self.n_steps + evdata.position = pos + self.draw_event(evdata, EVENT_DRAW_CP) + index += 1 + + # Draw an event + # evdata: Event data + # mode: draw mode => EVENT_DRAW_NORMAL, EVENT_DRAW_CP, EVENT_DRAW_SEL + # row: row index (optimization parameter) + def draw_event(self, evdata, mode=EVENT_DRAW_NORMAL, row=None): + step = evdata.position + # Calculate row if needed: + if row is None: + row = self.get_row_from_note(evdata.val1_start) + # Nothing to plot is event's note hasn't a row + if row is None: + return + + #logging.debug(f"DRAWING EVENT AT CELL {step}, {row}") + + velocity_colour = evdata.val2_start + 70 + if mode == EVENT_DRAW_CP: + cell_tag = f"cp_{step},{row}" + cell_tags = (cell_tag, f"step{step}", "gridcell", "cp") + fill_colour = f"#{velocity_colour//2:02x}{velocity_colour:02x}{velocity_colour//2:02x}" + else: + cell_tag = f"pat_{step},{row}" + cell_tags = (cell_tag, f"step{step}", "gridcell", "pat") + if mode == EVENT_DRAW_SEL: + fill_colour = f"#{velocity_colour//2:02x}{velocity_colour//2:02x}{velocity_colour:02x}" + else: + fill_colour = f"#{velocity_colour:02x}{velocity_colour:02x}{velocity_colour:02x}" + if evdata.play_freq == 0 or evdata.play_chance == 0: + stipple = 'gray12' + else: + stipple = '' + + coord = self.get_cell(step, row, evdata.duration, evdata.offset) + cells = self.grid_canvas.find_withtag(cell_tag) + if cells: + # Update existing cell + self.grid_canvas.coords(cells[0], coord) + self.grid_canvas.itemconfig(cells[0], fill=fill_colour, stipple=stipple, tags=cell_tags) + else: + # Create new cell + self.grid_canvas.create_rectangle(coord, width=0, fill=fill_colour, stipple=stipple, tags=cell_tags) + + # Redraw cell decoration + deco_tag = f"deco_{cell_tag}" + self.grid_canvas.delete(deco_tag) + deco_tags = cell_tags + (deco_tag,) + self._draw_cell_deco(coord, fill_colour, evdata, deco_tags) + + if step + evdata.duration > self.n_steps: + self.grid_canvas.itemconfig(f"lastnotetext{row}", text=f"+{evdata.duration - self.n_steps + step}", state="normal") + + def _draw_cell_deco(self, coord, fill_color, evdata, tags): + # bright background - dark text + #if (zynthian_gui_config.get_color_lux(fill_color) > 0.5): + if (evdata.val2_start >= 58): + deco_color = "#101010" + # dark background - light text + else: + deco_color = "#E0E0E0" + + if evdata.stut_speed > 0: + if evdata.stut_freq == 0 or evdata.stut_chance == 0: + stipple = 'gray25' + else: + stipple = '' + dx = self.step_width // (2 * evdata.stut_speed) + if dx < 2: + dx = 2 + # Flat + if evdata.stut_velfx == 0: + w = self.row_height // 2 + y = coord[3] - w // 2 + self.grid_canvas.create_line(coord[0] + 1, y, coord[2] - 1, y, + fill=deco_color, stipple=stipple, width=w, dash=(dx, dx), dashoffset=dx, tags=tags) + label_anchor = tkinter.CENTER + label_x = (coord[0] + coord[2]) // 2 + else: + w = self.row_height - 1 + y = (coord[1] + coord[3]) // 2 + self.grid_canvas.create_line(coord[0] + 1, y, coord[2] - 1, y, + fill=deco_color, stipple=stipple, width=w, dash=(dx, dx), dashoffset=dx, tags=tags) + # Fade-in + if evdata.stut_velfx == 1: + self.grid_canvas.create_polygon(coord[0], coord[1], coord[2], coord[1], coord[0], coord[3], + fill=fill_color, tags=tags) + label_anchor = tkinter.W + label_x = coord[0] + # Fade-out + elif evdata.stut_velfx == 2: + self.grid_canvas.create_polygon(coord[0], coord[1], coord[2], coord[3], coord[2], coord[1], + fill=fill_color, tags=tags) + label_anchor = tkinter.E + label_x = coord[2] + else: + label_anchor = tkinter.CENTER + label_x = (coord[0] + coord[2]) // 2 + + label_txt = None + if evdata.play_freq > 1: + label_txt = PLAY_FREQ_OPTIONS[evdata.play_freq] + elif evdata.play_chance < 1.0: + label_txt = f"{round(100 * evdata.play_chance)}%" + elif evdata.stut_speed > 0: + if evdata.stut_freq > 1: + label_txt = STUT_FREQ_OPTIONS[evdata.stut_freq] + elif evdata.stut_chance < 1.0: + label_txt = f"{round(100 * evdata.stut_chance)}%" + if label_txt: + label_y = (coord[1] + coord[3]) // 2 + self.grid_canvas.create_text(label_x, label_y, + fill=deco_color, text=label_txt, anchor=label_anchor, tags=tags) + + # Function to draw a grid row + # row: Row number (keymap index) + def draw_row(self, row): + # Flush modified flag to avoid refresh redrawing whole grid => Is this OK? + self.zynseq.libseq.isPatternModified() + + self.grid_canvas.itemconfig(f"lastnotetext{row}", state="hidden") + for step in range(self.n_steps): + self._draw_cell(step, row) + + def draw_cell(self, step, row): + # Flush modified flag to avoid refresh redrawing whole grid => Is this OK? + self.zynseq.libseq.isPatternModified() + # Call _draw_cell + self._draw_cell(step, row) + + # Function to draw a grid cell + # step: Step (column) index + # row: Index of row + def _draw_cell(self, step, row): + note = self.keymap[row]["note"] + if self.block_copied: + evdata = self.zynseq.get_note_data(step - self.block_dstep, note - self.block_drow, True) + else: + evdata = None + if evdata: + mode = EVENT_DRAW_CP + else: + index = self.zynseq.libseq.getNoteIndex(step, note) + if index >= 0: + evdata = zynseq.event_data() + self.zynseq.libseq.getEventDataAt(index, evdata) + if self.selected_events and index in self.selected_events: + mode = EVENT_DRAW_SEL + else: + mode = EVENT_DRAW_NORMAL + else: + mode = EVENT_DRAW_NORMAL + evdata = None + if evdata: + self.draw_event(evdata, mode, row) + else: + if mode == EVENT_DRAW_CP: + self.grid_canvas.delete(f"cp_{step},{row}") + else: + self.grid_canvas.delete(f"pat_{step},{row}") + + def redraw_grid_pending(self): + if self.grid_rows != len(self.keymap) or self.grid_steps != self.n_steps: + self.grid_rows = len(self.keymap) + self.grid_steps = self.n_steps + self.grid_canvas.delete(tkinter.ALL) + self.rect_selected_cell = None + self.rect_selected_block = None + self.draw_pianoroll() + self.redraw_pending = 4 + self.play_canvas.coords("playCursor", 1 + self.playhead * self.step_width, + 0, 1 + self.step_width * (self.playhead + 1), PLAYHEAD_HEIGHT) + + super().redraw_grid_pending() + + if self.redraw_pending > 1: + if self.redraw_pending > 3: + self.piano_roll.delete("notename") + self.grid_canvas.delete("gridhline") + + if self.redraw_pending > 2: + row_min = 0 + row_max = len(self.keymap) + else: + row_min = self.selected_cell[1] + row_max = self.selected_cell[1] + + for row in range(row_min, row_max): + # Create last note labels in grid + self.grid_canvas.create_text(self.total_width - self.select_thickness, + int(self.row_height * (row - 0.5)), + state=tkinter.HIDDEN, font=self.grid_font, anchor=tkinter.E, + tags=(f"lastnotetext{row}", "lastnotetext", "gridcell")) + if self.redraw_pending > 3: + self.pianoroll_set_row(row) + ypos = self.total_height - row * self.row_height + if self.keymap[row]['note'] % 12 == self.zynseq.libseq.getTonic(): + self.grid_canvas.create_line(0, ypos, self.total_width, ypos, fill=GRID_LINE_STRONG, tags="gridhline") + else: + self.grid_canvas.create_line(0, ypos, self.total_width, ypos, fill=GRID_LINE_WEAK, tags="gridhline") + + # Draw row of note cells + if self.redraw_pending <= 2: + self.draw_row(row) + + # Draw all notes + if self.redraw_pending > 2: + self.draw_events() + + # Set z-order to avoid vertical inlines overlapping note cells + if self.redraw_pending > 2: + self.grid_canvas.tag_lower("gridvline") + + # Function to draw pianoroll key outlines (does not fill key colour) + def draw_pianoroll(self): + self.piano_roll.delete(tkinter.ALL) + for row in range(0, len(self.keymap)): + x1 = 0 + y1 = self.total_height - (row + 1) * self.row_height + 1 + x2 = self.piano_roll_width + y2 = y1 + self.row_height - 1 + tags = f"row{row}" + self.piano_roll.create_rectangle(x1, y1, x2, y2, width=0, tags=tags) + + def pianoroll_set_row(self, row, color=None): + row_id = f"row{row}" + try: + name = self.keymap[row]["name"] + except: + name = None + if color is None: + if "colour" in self.keymap[row]: + color = self.keymap[row]["colour"] + elif name and "#" in name: + color = "black" + else: + color = "white" + if color == "black": + fill = "white" + else: + fill = CANVAS_BACKGROUND + else: + fill = CANVAS_BACKGROUND + self.piano_roll.itemconfig(row_id, fill=color) + # name = str(row) + if name: + tag = f"notename{row}" + res = self.piano_roll.find_withtag(tag) + if res: + self.piano_roll.itemconfig(res[0], fill=fill) + else: + ypos = self.total_height - row * self.row_height + self.piano_roll.create_text(2, ypos - 0.5 * self.row_height, text=name, font=self.grid_font, + anchor="w", fill=fill, tags=(tag, "notename")) + + def pianoroll_note_on(self, note): + # Highlight the note key + row = self.get_row_from_note(note) + if row is not None: + self.pianoroll_set_row(row, "#40FF40") + + # Re-center vertically if note is off the view area + if not self.keymap_offset <= row < self.keymap_offset + self.view_rows: + self.set_keymap_offset(row - self.view_rows // 2 + 1) + self.select_cell(None, row) + + def pianoroll_note_off(self, note): + row = self.get_row_from_note(note) + if row is not None: + self.pianoroll_set_row(row) + + # Function to update selectedCell + # step: Step (column) of selected cell (Optional - default to reselect current column) + # row: Index of keymap to select (Optional - default to reselect current row). + # Maybe outside visible range to scroll display + def select_cell(self, step=None, row=None): + if not self.keymap: + return + # Check row boundaries + if row is None: + row = self.selected_cell[1] + if row < 0: + row = 0 + elif row >= len(self.keymap): + row = len(self.keymap) - 1 + else: + row = int(row) + # Check keymap offset + if row >= self.keymap_offset + self.view_rows: + # Note is off top of view area + self.set_keymap_offset(row - self.view_rows + 1) + elif row < self.keymap_offset: + # Note is off bottom of view area + self.set_keymap_offset(row) + note = self.keymap[row]['note'] + + # Check column boundaries + if step is None: + step = self.selected_cell[0] + if step < 0: + step = 0 + elif step >= self.n_steps: + step = self.n_steps - 1 + else: + step = int(step) + # Skip hidden (overlapping) cells + for previous in range(step - 1, -1, -1): + prev_duration = ceil(self.zynseq.libseq.getNoteDuration(previous, note)) + if not prev_duration: + continue + if prev_duration > step - previous: + if step > self.selected_cell[0]: + step = previous + prev_duration + else: + step = previous + break + # Re-check column boundaries + if step < 0: + step = 0 + elif step >= self.n_steps: + step = self.n_steps - 1 + # Check step offset + if step >= self.step_offset + int(self.view_steps): + # Step is off right of display + self.set_step_offset(step - int(self.view_steps) + 1) + elif step < self.step_offset: + # Step is off left of display + self.set_step_offset(step) + self.selected_cell = [step, row] + + if self.edit_mode == EDIT_MODE_BLOCK: + return + + # Duration & velocity + evdata = self.zynseq.get_note_data(step, note) + if evdata: + duration = evdata.duration + offset = evdata.offset + velocity = evdata.val2_start + else: + duration = self.duration + offset = 0.0 + velocity = self.velocity + self.set_velocity_indicator(velocity) + + # Hide selected block and copy/paste notes + self.grid_canvas.delete("cp") + self.hide_selected_block() + + # Position selector cell-frame + coord = self.get_cell(step, row, duration, offset) + coord[0] -= 1 + coord[1] -= 1 + if not self.rect_selected_cell: + self.rect_selected_cell = self.grid_canvas.create_rectangle(coord, fill="", outline=SELECT_BORDER, + width=self.select_thickness, tags="selected_cell") + else: + self.grid_canvas.coords(self.rect_selected_cell, coord) + self.grid_canvas.tag_raise(self.rect_selected_cell) + + # --------------------------------------------------------------- + # Block edit functionality => Copy/paste block + # --------------------------------------------------------------- + + def move_cell(self, cell, dstep, drow): + inrange = True + if dstep: + cell[0] += dstep + if cell[0] >= self.n_steps: + cell[0] = self.n_steps - 1 + inrange = False + elif cell[0] < 0: + cell[0] = 0 + inrange = False + if drow: + cell[1] += drow + if cell[1] >= len(self.keymap): + cell[1] = len(self.keymap) - 1 + inrange = False + elif cell[1] < 0: + cell[1] = 0 + inrange = False + return inrange + + # ------------------------------------------------------------------------- + # Event management + # ------------------------------------------------------------------------- + + # Function to toggle note event + # step: step number (column) + # row: keymap index + # Returns: Note if note added else None + def toggle_event(self, step, row): + if step < 0 or step >= self.n_steps or row >= len(self.keymap): + return + note = self.keymap[row]['note'] + start_step = self.zynseq.libseq.getNoteStart(step, note) + if start_step >= 0: + self.remove_chord(start_step, row) + else: + self.add_chord(step, row, self.velocity, self.duration, self.offset) + self.select_cell(None, row) + + # Function to remove an event + # step: step number (column) + # row: keymap index + def remove_event(self, step, row): + if row >= len(self.keymap): + return + self.save_pattern_snapshot(now=True, force=False) + note = self.keymap[row]['note'] + self.zynseq.libseq.removeNote(step, note) + # Silence note if sounding + self.zynseq.libseq.playNote(note, 0, self.channel) + self.save_pattern_snapshot(now=True, force=True) + self.drawing = True + self.draw_row(row) + self.drawing = False + self.select_cell(step, row) + + # Function to add a note or chord, depending on current chord mode + # step: step number (column) + # row: keymap index + # vel: velocity (0-127) + # dur: duration (in steps) + # offset: offset of start of event (0..0.99) + def add_chord(self, step, row, vel, dur, offset=0.0): + note = self.keymap[row]["note"] + match self.chord_mode: + case 0: + # Single note entry + chord = [0] + case 1: + # Chord entry mode + chord = CHORDS[self.chord_type][1] + case _: + # Diatonic chord entry mode + chord = self.get_diatonic_chord(note) + for note_offset in chord: + if self.add_note_event(step, row + note_offset, vel, dur, offset): + self.play_note(note + note_offset) + + # Function to remove a note or chord, depending on current chord mode + # step: step number (column) + # note: MIDI note (0-127) + # vel: velocity (0-127) + # dur: duration (in steps) + # offset: offset of start of event (0..0.99) + def remove_chord(self, step, row): + match self.chord_mode: + case 0: + # Single note entry + chord = [0] + case 1: + # Chord entry mode + chord = CHORDS[self.chord_type][1] + case _: + # Diatonic chord entry mode + note = self.keymap[row]["note"] + chord = self.get_diatonic_chord(note) + for offset in chord: + self.remove_event(step, row + offset) + + + def get_default_note_evdata(self): + evdata = zynseq.event_data() + evdata.set_values(0, self.offset, self.duration, 0x90, 0, self.velocity, 0, 0, + self.stut_speed, self.stut_velfx, self.stut_ramp, + self.play_freq, self.stut_freq, self.play_chance, self.stut_chance) + return evdata + + # Function to add an event + # step: step number (column) + # row: keymap index + # vel: velocity (0-127) + # dur: duration (in steps) + # offset: offset of start of event (0..0.99) + def add_note_event(self, step, row, vel, dur, offset=0.0, new_note=True): + self.save_pattern_snapshot(now=True, force=False) + note = self.keymap[row]["note"] + if note > 127: + return False + self.zynseq.libseq.addNote(step, note, vel, dur, offset) + if new_note: + self.zynseq.libseq.setNoteData(step, note, self.get_default_note_evdata()) + self.save_pattern_snapshot(now=True, force=True) + self.drawing = True + self.draw_row(row) + self.drawing = False + self.select_cell(step, row) + return True + + # Function to refresh status + def refresh_status(self): + super().refresh_status() + self.playstate = self.zynseq.libseq.getSequenceState(self.zynseq.scene, self.phrase, self.sequence) & 0xff + step = self.zynseq.libseq.getPatternPlayhead() + if self.playhead != step: + self.playhead = step + self.play_canvas.coords("playCursor", + 1 + self.playhead * self.step_width, 0, + 1 + self.step_width * (self.playhead + 1), PLAYHEAD_HEIGHT) + if (self.zynseq.libseq.isPatternModified() or self.reload_keymap) and self.redraw_pending < 3: + self.redraw_pending = 3 + if self.reload_keymap: + self.load_keymap() + self.reload_keymap = False + self.set_keymap_offset() + if self.redraw_pending: + self.draw_grid() + if not self.drawing: + pending_rows = set() + while not self.rows_pending.empty(): + pending_rows.add(self.rows_pending.get_nowait()) + while len(pending_rows): + self.draw_row(pending_rows.pop()) + self.save_pattern_snapshot(now=False, force=False) + + # Function to handle MIDI notes (only used to refresh screen - actual MIDI input handled by lib) + def midi_note_on(self, note): + self.pianoroll_note_on(note) + if self.zynseq.libseq.isMidiRecord(): + self.rows_pending.put_nowait(note) + + def midi_note_off(self, note): + self.pianoroll_note_off(note) + if self.zynseq.libseq.isMidiRecord(): + if self.playstate == zynseq.SEQ_STOPPED: + self.save_pattern_snapshot(now=True, force=True) + else: + self.changed = True + self.rows_pending.put_nowait(note) + + def set_edit_title(self): + color_fg = zynthian_gui_config.color_header_bg + color_bg = zynthian_gui_config.color_panel_tx + step = self.selected_cell[0] + note = self.get_note_from_row(self.selected_cell[1]) + delta = "1" + zynpot = 2 + if self.edit_mode == EDIT_MODE_MULTI: + if self.edit_param == EDIT_PARAM_DUR: + delta = "0.1" + zynpot = 1 + self.set_title("MULTI Duration ", color_fg, color_bg) + elif self.edit_param == EDIT_PARAM_VEL: + self.set_title("MULTI Velocity", color_fg, color_bg) + else: + evdata = self.zynseq.get_note_data(step, note) + if self.edit_param == EDIT_PARAM_DUR: + if evdata: + val = evdata.duration + else: + val = self.duration + self.set_title(f"Duration: {val:0.1f} steps", color_fg, color_bg) + delta = "0.1" + zynpot = 1 + elif self.edit_param == EDIT_PARAM_VEL: + if evdata: + val = evdata.val2_start + else: + val = self.velocity + self.set_title(f"Velocity: {val}", color_fg, color_bg) + elif self.edit_param == EDIT_PARAM_OFFSET: + if evdata: + val = evdata.offset + else: + val = self.offset + val = round(100 * val) + self.set_title(f"Offset: {val}%", color_fg, color_bg) + elif self.edit_param == EDIT_PARAM_STUT_SPD: + if evdata: + val = evdata.stut_speed + else: + val = self.stut_speed + self.set_title(f"Stutter speed: {val}", color_fg, color_bg) + elif self.edit_param == EDIT_PARAM_STUT_VFX: + if evdata: + val = evdata.stut_velfx + else: + val = self.stut_velfx + val = STUT_VFX_OPTIONS[val] + self.set_title(f"Stutter velo: {val}", color_fg, color_bg) + elif self.edit_param == EDIT_PARAM_STUT_RMP: + if evdata: + val = evdata.stut_ramp + else: + val = self.stut_ramp + val = STUT_RMP_OPTIONS[val] + self.set_title(f"Stutter ramp: {val}", color_fg, color_bg) + elif self.edit_param == EDIT_PARAM_PLAY_CHANCE: + if evdata: + val = evdata.play_chance + else: + val = self.play_chance + val = round(100 * val) + self.set_title(f"Play chance: {val}%", color_fg, color_bg) + elif self.edit_param == EDIT_PARAM_PLAY_FREQ: + if evdata: + val = evdata.play_freq + else: + val = self.play_freq + val = PLAY_FREQ_OPTIONS[val] + self.set_title(f"Play frequency: {val}", color_fg, color_bg) + elif self.edit_param == EDIT_PARAM_STUT_CHANCE: + if evdata: + val = evdata.stut_chance + else: + val = self.stut_chance + val = round(100 * val) + self.set_title(f"Stutter chance: {val}%", color_fg, color_bg) + elif self.edit_param == EDIT_PARAM_STUT_FREQ: + if evdata: + val = evdata.stut_freq + else: + val = self.stut_freq + val = STUT_FREQ_OPTIONS[val] + self.set_title(f"Stutter frequency: {val}", color_fg, color_bg) + + self.init_buttonbar([(f"ZYNPOT {zynpot},-1", f"-{delta}"), + (f"ZYNPOT {zynpot},+1", f"+{delta}"), + ("ZYNPOT 3,-1", "PREV\nPARAM"), + ("ZYNPOT 3,+1", "NEXT\nPARAM"), + (3, "OK")]) + + # Function to handle zynpots value change + # i: Zynpot index [0..n] + # dval: Current value of zyncoder + def zynpot_cb(self, i, dval): + if zynthian_gui_base.zynpot_cb(self, i, dval): + return True + + if i == self.ctrl_order[1]: + if self.edit_mode == EDIT_MODE_SINGLE: + if self.edit_param == EDIT_PARAM_DUR: + step = self.selected_cell[0] + index = self.selected_cell[1] + note = self.keymap[index]['note'] + evdata = self.zynseq.get_note_data(step, note) + if evdata: + duration = evdata.duration + else: + duration = self.duration + duration += 0.1 * dval + max_duration = self.n_steps + if duration > max_duration or duration < 0.05: + return + if evdata: + self.add_note_event(step, index, evdata.val2_start, duration, evdata.offset, new_note=False) + #self.add_chord(step, index, sel_velocity, duration, sel_offset) + else: + self.duration = duration + self.select_cell() + self.set_edit_title() + return True + elif self.edit_mode == EDIT_MODE_MULTI: + if self.edit_param == EDIT_PARAM_DUR: + if self.selected_events: + self.zynseq.libseq.changeDurationList(dval * 0.1, zynseq.event_indexes_buffer, len(self.selected_events)) + else: + self.zynseq.libseq.changeDurationAll(dval * 0.1) + self.redraw_pending = 3 + return True + + elif i == self.ctrl_order[2]: + if self.edit_mode == EDIT_MODE_SINGLE: + step = self.selected_cell[0] + row = self.selected_cell[1] + note = self.keymap[row]['note'] + evdata = self.zynseq.get_note_data(step, note) + if self.edit_param == EDIT_PARAM_DUR: + if evdata: + val = evdata.duration + else: + val = self.duration + val += dval + if val > self.n_steps or val < 0.05: + return + if evdata: + self.add_note_event(step, row, evdata.val2_start, val, evdata.offset, new_note=False) + else: + self.duration = val + self.select_cell() + elif self.edit_param == EDIT_PARAM_VEL: + if evdata: + val = evdata.val2_start + else: + val = self.velocity + val += dval + if val > 127 or val < 1: + return + self.set_velocity_indicator(val) + if evdata: + self.zynseq.libseq.setNoteVelocity(step, note, val) + self.draw_cell(step, row) + else: + self.velocity = val + self.select_cell() + elif self.edit_param == EDIT_PARAM_OFFSET: + if evdata: + val = evdata.offset + else: + val = self.offset + val = round(100 * val) + dval + if val < 0 or val > 99: + return + if evdata: + self.zynseq.libseq.setNoteOffset(step, note, val/100) + self.draw_cell(step, row) + else: + self.offset = val/100 + self.select_cell() + elif self.edit_param == EDIT_PARAM_STUT_SPD: + if evdata: + val = evdata.stut_speed + else: + val = self.stut_speed + val += dval + if val < 0 or val > 32: + return + if evdata: + self.zynseq.libseq.setNoteStutterSpeed(step, note, val) + self.draw_cell(step, row) + else: + self.stut_speed = val + self.select_cell() + elif self.edit_param == EDIT_PARAM_STUT_VFX: + if evdata: + val = evdata.stut_velfx + else: + val = self.stut_velfx + val += dval + if val < 0 or val >= len(STUT_VFX_OPTIONS): + return True + if evdata: + self.zynseq.libseq.setNoteStutterVelfx(step, note, val) + self.draw_cell(step, row) + else: + self.stut_velfx = val + self.select_cell() + elif self.edit_param == EDIT_PARAM_STUT_RMP: + if evdata: + val = evdata.stut_ramp + else: + val = self.stut_ramp + val += dval + if val < 0 or val >= len(STUT_RMP_OPTIONS): + return True + if evdata: + self.zynseq.libseq.setNoteStutterRamp(step, note, val) + self.draw_cell(step, row) + else: + self.stut_ramp = val + self.select_cell() + elif self.edit_param == EDIT_PARAM_PLAY_CHANCE: + if evdata: + val = evdata.play_chance + else: + val = self.play_chance + val = round(100 * val) + dval + if val < 0 or val > 100: + return True + if evdata: + self.zynseq.libseq.setNotePlayChance(step, note, val/100) + self.draw_cell(step, row) + else: + self.play_chance = val/100 + self.select_cell() + elif self.edit_param == EDIT_PARAM_PLAY_FREQ: + if evdata: + val = evdata.play_freq + else: + val = self.play_freq + val += dval + if val < 0 or val >= len(PLAY_FREQ_OPTIONS): + return True + if evdata: + self.zynseq.libseq.setNotePlayFreq(step, note, val) + self.draw_cell(step, row) + else: + self.play_freq = val + self.select_cell() + elif self.edit_param == EDIT_PARAM_STUT_CHANCE: + if evdata: + val = evdata.stut_chance + else: + val = self.stut_chance + val = round(100 * val) + dval + if val < 0 or val > 100: + return True + if evdata: + self.zynseq.libseq.setNoteStutterChance(step, note, val/100) + self.draw_cell(step, row) + else: + self.stut_chance = val/100 + self.select_cell() + elif self.edit_param == EDIT_PARAM_STUT_FREQ: + if evdata: + val = evdata.stut_freq + else: + val = self.stut_freq + val += dval + if val < 0 or val >= len(STUT_FREQ_OPTIONS): + return True + if evdata: + self.zynseq.libseq.setNoteStutterFreq(step, note, val) + self.draw_cell(step, row) + else: + self.stut_freq = val + self.select_cell() + self.set_edit_title() + return True + elif self.edit_mode == EDIT_MODE_MULTI: + if self.edit_param == EDIT_PARAM_DUR: + if self.selected_events: + self.zynseq.libseq.changeDurationList(dval, zynseq.event_indexes_buffer, len(self.selected_events)) + else: + self.zynseq.libseq.changeDurationAll(dval) + self.redraw_pending = 3 + elif self.edit_param == EDIT_PARAM_VEL: + if self.selected_events: + self.zynseq.libseq.changeVelocityList(dval, zynseq.event_indexes_buffer, len(self.selected_events)) + else: + self.zynseq.libseq.changeVelocityAll(dval) + self.redraw_pending = 3 + return True + + elif i == self.ctrl_order[3]: + if self.edit_mode in (EDIT_MODE_SINGLE, EDIT_MODE_MULTI): + self.edit_param += dval + if self.edit_param < 0: + self.edit_param = 0 + if self.edit_param > EDIT_PARAM_LAST: + self.edit_param = EDIT_PARAM_LAST + self.set_edit_title() + return True + + if super().zynpot_cb(i, dval): + return True + + +# ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_gui_patterneditor.py b/zyngui/zynthian_gui_patterneditor.py deleted file mode 100644 index fa52c2c6b..000000000 --- a/zyngui/zynthian_gui_patterneditor.py +++ /dev/null @@ -1,2313 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# ****************************************************************************** -# ZYNTHIAN PROJECT: Zynthian GUI -# -# Zynthian GUI Step-Sequencer Pattern Editor Class -# -# Copyright (C) 2015-2025 Fernando Moyano -# Brian Walton -# -# ****************************************************************************** -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License as -# published by the Free Software Foundation; either version 2 of -# the License, or any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# For a full copy of the GNU General Public License see the LICENSE.txt file. -# -# ****************************************************************************** - -import os -import json -import tkinter -import logging -from math import ceil -from queue import Queue -from xml.dom import minidom -from datetime import datetime -import tkinter.font as tkFont - -# Zynthian specific modules -from zyncoder.zyncore import lib_zyncore -from zynlibs.zynseq import zynseq -from zynlibs.zynsmf import zynsmf -from . import zynthian_gui_base -from zyngui import zynthian_gui_config -from zyngui.multitouch import MultitouchTypes - - -# ------------------------------------------------------------------------------ -# Zynthian Step-Sequencer Pattern Editor GUI Class -# ------------------------------------------------------------------------------ - -# Local constants -SELECT_BORDER = zynthian_gui_config.color_on -PLAYHEAD_CURSOR = zynthian_gui_config.color_on -CANVAS_BACKGROUND = zynthian_gui_config.color_panel_bd -GRID_LINE_WEAK = zynthian_gui_config.color_panel_bg -GRID_LINE_STRONG = zynthian_gui_config.color_tx_off -PLAYHEAD_BACKGROUND = zynthian_gui_config.color_variant( - zynthian_gui_config.color_panel_bd, 40) -PLAYHEAD_LINE = zynthian_gui_config.color_tx_off -PLAYHEAD_HEIGHT = 12 -CONFIG_ROOT = "/zynthian/zynthian-data/zynseq" - -DEFAULT_VIEW_STEPS = 16 -DEFAULT_VIEW_ROWS = 16 -DRAG_SENSIBILITY = 1.5 -SAVE_SNAPSHOT_DELAY = 10 - -EDIT_MODE_NONE = 0 # Edit mode disabled -EDIT_MODE_SINGLE = 1 # Edit mode enabled for selected note -EDIT_MODE_ALL = 2 # Edit mode enabled for all notes -EDIT_MODE_ZOOM = 3 # Zoom mode -EDIT_MODE_HISTORY = 4 # Edit history mode (undo/redo) - -EDIT_PARAM_DUR = 0 # Edit event duration -EDIT_PARAM_VEL = 1 # Edit event velocity -EDIT_PARAM_OFFSET = 2 # Edit event offset -EDIT_PARAM_STUT_CNT = 3 # Edit note stutter count -EDIT_PARAM_STUT_DUR = 4 # Edit note stutter duration -EDIT_PARAM_CHANCE = 5 # Edit note play chance -EDIT_PARAM_CHORD_MODE = 6 # Edit chord entry mode -EDIT_PARAM_CHORD_TYPE = 7 # Edit chord type -EDIT_PARAM_LAST = 7 # Index of last parameter - -SHOW_NOTES = 0 # Display note events -SHOW_CC = 1 # Display CC events - -# List of permissible steps per beat -STEPS_PER_BEAT = [1, 2, 3, 4, 6, 8, 12, 24] -INPUT_CHANNEL_LABELS = ['OFF', 'ANY', '1', '2', '3', '4', '5', - '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16'] -NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] -SCALES = { - "major": [0, 2, 4, 5, 7, 9, 11, 12, 14, 16, 17, 19, 21, 23], - "minor": [0, 2, 3, 5, 7, 8, 10, 12, 14, 15, 17, 19, 20, 22] -} - -CHORD_MODES = [ - "Single note", - "Chord", - "Diatonic triads, major key", - "Diatonic 7ths, major key", - "Diatonic triads, minor key", - "Diatonic 7ths, minor key" -] - -CHORDS = [ - # Triads - ["Major", [0, 4, 7]], - ["Minor", [0, 3, 7]], - ["Diminished", [0, 3, 6]], - ["Augmented", [0, 4, 8]], - # Seventh chords - ["Major 7th", [0, 4, 7, 11]], # (maj7) - ["Minor 7th", [0, 3, 7, 10]], # (m7) - ["Dominant 7th", [0, 4, 7, 10]], # (7) - ["Half-Diminished 7th", [0, 3, 6, 10]], # (m7♭5) - ["Diminished 7th", [0, 3, 6, 9]], # (dim7) - ["Minor-Major 7th", [0, 3, 7, 11]], # (m(maj7)) - ["Augmented Major 7th", [0, 4, 8, 11]], # (+maj7) - ["Augmented 7th", [0, 4, 8, 10]], # (+7) - # Extended chords - ["Major 9th", [0, 4, 7, 11, 14]], # (maj9) - ["Dominant 9th", [0, 4, 7, 10, 14]], # (9) - ["Minor 9th", [0, 3, 7, 10, 14]], # (m9) - ["Minor-Major 9th", [0, 3, 7, 11, 14]], # (m(maj9)) - ["Dominant 11th", [0, 4, 7, 10, 14, 17]], # (11) - ["Minor 11th", [0, 3, 7, 10, 14, 17]], # (m11) - ["Dominant 13th", [0, 4, 7, 10, 14, 17, 21]], # (13) - ["Minor 13th", [0, 3, 7, 10, 14, 17, 21]], # (m13) - # Suspended chords - ["Suspended 2nd", [0, 2, 7]], # (sus2) - ["Suspended 4th", [0, 5, 7]], # (sus4) - ["7sus4", [0, 5, 7, 10]], - # Add chords - ["Add9", [0, 4, 7, 14]], - ["Minor Add9", [0, 3, 7, 14]], # (madd9) - # 6th chords - ["Major 6th", [0, 4, 7, 9]], # (6) - ["Minor 6th", [0, 3, 7, 9]], # (m6) - # Altered 7th chords - ["Half-Diminished Dominant", [0, 4, 6, 10]] # (7♭5) -] - -# ----------------------------------------------------------------------------- -# Class implements step sequencer pattern editor -# ----------------------------------------------------------------------------- - - -class zynthian_gui_patterneditor(zynthian_gui_base.zynthian_gui_base): - - # Function to initialise class - def __init__(self): - - super().__init__() - self.zynseq_dpath = os.environ.get( - 'ZYNTHIAN_DATA_DIR', "/zynthian/zynthian-data") + "/zynseq" - self.patterns_dpath = self.zynseq_dpath + "/patterns" - self.my_zynseq_dpath = os.environ.get( - 'ZYNTHIAN_MY_DATA_DIR', "/zynthian/zynthian-my-data") + "/zynseq" - self.my_patterns_dpath = self.my_zynseq_dpath + "/patterns" - self.my_captures_dpath = os.environ.get( - 'ZYNTHIAN_MY_DATA_DIR', "/zynthian/zynthian-my-data") + "/capture" - - self.state_manager = self.zyngui.state_manager - self.zynseq = self.state_manager.zynseq - - self.ctrl_order = zynthian_gui_config.layout['ctrl_order'] - - self.title = "Pattern 0" - self.edit_mode = EDIT_MODE_NONE # Enable encoders to adjust note parameters - self.edit_param = EDIT_PARAM_DUR # Parameter to adjust in parameter edit mode - self.duration = 1.0 # Current note entry duration - self.velocity = 100 # Current note entry velocity - self.copy_source = 1 # Index of pattern to copy - self.bank = None # Bank used for pattern editor sequence player - self.pattern = 0 # Pattern to edit - self.sequence = None # Sequence used for pattern editor sequence player - self.last_play_mode = zynseq.SEQ_LOOP - self.playhead = 0 - self.playstate = zynseq.SEQ_STOPPED - self.n_steps = 0 # Number of steps in current pattern - self.n_steps_beat = 0 # Number of steps per beat (current pattern) - self.keymap_offset = 60 # MIDI note number of bottom row in grid - self.step_offset = 0 # Step number of left column in grid - self.selected_cell = [0, 60] # Location of selected cell (column,row) - # Array of {"note":MIDI_NOTE_NUMBER, "name":"key name","colour":"key colour"} name and colour are optional - self.keymap = [] - self.reload_keymap = False # Signal keymap needs reloading - self.cells = [] # Array of cells indices - # What to redraw: 0=nothing, 1=selected cell, 2=selected row, 3=refresh grid, 4=rebuild grid - self.redraw_pending = 4 - self.rows_pending = Queue() - self.channel = 0 - self.drawing = False # mutex to avoid concurrent screen draws - self.changed = False - self.changed_ts = 0 - self.midi_record = False # True when record from MIDI enabled - self.chord_mode = 0 # Chord entry mode. 0 for single note entry - self.chord_type = 0 # Chord type. Index of CHORD - self.diatonic_scale_tonic = 0 # Tonic of diatonic scale used for chords - self.display_mode = SHOW_NOTES - - # Touch control variables - self.swiping = 0 - self.swipe_friction = 0.8 - self.swipe_step_dir = 0 - self.swipe_row_dir = 0 - self.swipe_step_speed = 0 - self.swipe_row_speed = 0 - self.swipe_step_offset = 0 - self.swipe_row_offset = 0 - self.grid_drag_start = None # Coordinates at start of grid drag - self.drag_start_velocity = None # Velocity value at start of drag - self.drag_note = False # True if dragging note in grid - self.drag_velocity = False # True indicates drag will adjust velocity - self.drag_duration = False # True indicates drag will adjust duration - self.drag_duration = False # True indicates drag will adjust duration - self.piano_roll_drag_start = None - self.piano_roll_drag_count = 0 - - # Geometry contants - self.grid_height = self.height - PLAYHEAD_HEIGHT - self.grid_width = int(self.width * 0.91) - self.base_row_height = self.grid_height // DEFAULT_VIEW_ROWS - self.base_step_width = self.grid_width // DEFAULT_VIEW_STEPS - self.piano_roll_width = self.width - self.grid_width - # Scale thickness of select border based on screen resolution - self.select_thickness = 1 + int(self.width / 500) - # Geometry variables => Change with zoom - self.zoom = 0 # Negative / Zero / Positive - # Quantity of rows (notes) displayed in grid - self.view_rows = DEFAULT_VIEW_ROWS - # Quantity of columns (steps) displayed in grid - self.view_steps = DEFAULT_VIEW_STEPS - self.row_height = self.base_row_height - self.step_width = self.base_step_width - - # Create pattern grid canvas - self.grid_canvas = tkinter.Canvas(self.main_frame, - width=self.grid_width, - height=self.grid_height, - scrollregion=( - 0, 0, self.grid_width, self.grid_height), - bg=CANVAS_BACKGROUND, - bd=0, - highlightthickness=0) - self.update_geometry() - self.grid_canvas.grid(column=1, row=0) - self.grid_canvas.bind('', self.on_grid_press) - self.grid_canvas.bind('', self.on_grid_release) - self.grid_canvas.bind('', self.on_grid_drag) - self.zyngui.multitouch.tag_bind( - self.grid_canvas, None, "gesture", self.on_gesture) - - # Create velocity level indicator canvas - self.velocity_canvas = tkinter.Canvas(self.main_frame, - width=self.piano_roll_width, - height=PLAYHEAD_HEIGHT, - bg=PLAYHEAD_BACKGROUND, - bd=0, - highlightthickness=0) - self.velocity_canvas.create_rectangle( - 0, 0, self.piano_roll_width * self.velocity / 127, PLAYHEAD_HEIGHT, fill='yellow', tags="velocityIndicator", width=0) - self.velocity_canvas.grid(column=0, row=1) - - # Create pianoroll canvas - self.piano_roll = tkinter.Canvas(self.main_frame, - width=self.piano_roll_width, - height=self.grid_height, - scrollregion=( - 0, 0, self.piano_roll_width, self.total_height), - bg=CANVAS_BACKGROUND, - bd=0, - highlightthickness=0) - self.piano_roll.grid(row=0, column=0) - self.piano_roll.bind("", self.on_pianoroll_press) - self.piano_roll.bind("", self.on_pianoroll_release) - self.piano_roll.bind("", self.on_pianoroll_motion) - self.piano_roll.bind("", self.on_pianoroll_wheel) - self.piano_roll.bind("", self.on_pianoroll_wheel) - - # Create playhead canvas - self.play_canvas = tkinter.Canvas(self.main_frame, - width=self.grid_width, - height=PLAYHEAD_HEIGHT, - scrollregion=( - 0, 0, self.grid_width, PLAYHEAD_HEIGHT), - bg=PLAYHEAD_BACKGROUND, - bd=0, - highlightthickness=0) - self.play_canvas.create_rectangle(0, 0, self.step_width, PLAYHEAD_HEIGHT, - fill=PLAYHEAD_CURSOR, - state="normal", - width=0, - tags="playCursor") - self.play_canvas.grid(column=1, row=1) - - self.zynseq.libseq.setPlayMode(0, 0, zynseq.SEQ_LOOP) - - # Load pattern 1 so that the editor has a default known state - self.load_pattern(1) - - # Function to get name of this view - def get_name(self): - return "pattern editor" - - def play_note(self, note): - if self.zynseq.libseq.getPlayState(self.bank, self.sequence) == zynseq.SEQ_STOPPED: - self.zynseq.libseq.playNote( - note, self.velocity, self.channel, int(200 * self.duration)) - - # Function to setup behaviour of encoders - def setup_zynpots(self): - for i in range(zynthian_gui_config.num_zynpots): - lib_zyncore.setup_behaviour_zynpot(i, 0) - - # Function to show GUI - def build_view(self): - if self.sequence is None: - self.sequence = 0 - if self.bank is None: - self.bank = 0 - if self.sequence == 0 and self.bank == 0: - self.zynseq.libseq.setGroup(self.bank, self.sequence, 0xFF) - self.zynseq.libseq.setSequence(self.bank, self.sequence) - self.copy_source = self.pattern - - self.setup_zynpots() - if not self.param_editor_zctrl: - title = self.zynseq.get_sequence_name(self.bank, self.sequence) - try: - str(int(title)) - # Get preset title from synth engine on this MIDI channel - midi_chan = self.zynseq.libseq.getChannel( - self.bank, self.sequence, 0) - preset_name = self.zyngui.chain_manager.get_synth_preset_name( - midi_chan) - if not preset_name: - group = chr( - 65 + self.zynseq.libseq.getGroup(self.bank, self.sequence)) - title = f"{group}{title}" - except: - pass - if self.chord_mode: - self.set_title(f"Pattern {self.pattern} [Chord Entry]", - zynthian_gui_config.color_panel_tx, zynthian_gui_config.color_header_bg) - elif title: - self.set_title(f"Pattern {self.pattern} ({title})") - else: - self.set_title(f"Pattern {self.pattern}") - self.last_play_mode = self.zynseq.libseq.getPlayMode( - self.bank, self.sequence) - if self.last_play_mode not in (zynseq.SEQ_LOOP, zynseq.SEQ_LOOPALL): - self.zynseq.libseq.setPlayMode( - self.bank, self.sequence, zynseq.SEQ_LOOP) - - # Set active the first chain with pattern's MIDI chan - try: - chain_id = self.zyngui.chain_manager.midi_chan_2_chain_ids[self.channel][0] - self.zyngui.chain_manager.set_active_chain_by_id(chain_id) - except: - logging.error( - f"Couldn't set active chain to channel {self.channel}.") - - self.toggle_midi_record(self.midi_record) - - return True - - # Function to hide GUI - def hide(self): - if not self.shown: - return - super().hide() - if self.bank == 0 and self.sequence == 0: - self.zynseq.libseq.setPlayState( - self.bank, self.sequence, zynseq.SEQ_STOPPED) - self.toggle_midi_record(False) - self.set_edit_mode(EDIT_MODE_NONE) - self.zynseq.libseq.setRefNote(int(self.keymap_offset)) - self.zynseq.libseq.setPatternZoom(self.zoom) - self.zynseq.libseq.setPlayMode( - self.bank, self.sequence, self.last_play_mode) - self.zyngui.alt_mode = False - - # Function to add menus - def show_menu(self): - self.disable_param_editor() - options = {} - extra_options = not zynthian_gui_config.check_wiring_layout([ - "Z2", "V5"]) - - # Global Options - if not self.zyngui.multitouch._f_device: - options['Grid zoom'] = 'Grid zoom' - if extra_options: - options['Tempo'] = 'Tempo' - if not zynthian_gui_config.check_wiring_layout(["Z2"]): - options['Arranger'] = 'Arranger' - options['Beats per Bar ({})'.format( - self.zynseq.libseq.getBeatsPerBar())] = 'Beats per bar' - if self.display_mode == SHOW_NOTES: - options['Show CC events'] = 'SHOW CC' - else: - options['Show Note events'] = 'SHOW NOTES' - - # Pattern Options - options['> PATTERN OPTIONS'] = None - options['Beats in pattern ({})'.format( - self.zynseq.libseq.getBeatsInPattern())] = 'Beats in pattern' - options['Steps/Beat ({})'.format(self.n_steps_beat)] = 'Steps per beat' - if self.zynseq.libseq.getQuantizeNotes(): - options['\u2612 Quantization'] = 'Quantization' - else: - options['\u2610 Quantization'] = 'Quantization' - options['Swing Divisor ({})'.format( - self.zynseq.libseq.getSwingDiv())] = 'Swing Divisor' - options['Swing Amount ({}%)'.format( - int(100.0 * self.zynseq.libseq.getSwingAmount()))] = 'Swing Amount' - options['Time Humanization ({})'.format( - int(500.0 * self.zynseq.libseq.getHumanTime()))] = 'Time Humanization' - options['Velocity Humanization ({})'.format( - int(self.zynseq.libseq.getHumanVelo()))] = 'Velocity Humanization' - options['Note Play Chance ({}%)'.format( - int(100.0 * self.zynseq.libseq.getPlayChance()))] = 'Note Play Chance' - scales = self.get_scales() - options['Scale ({})'.format( - scales[self.zynseq.libseq.getScale()])] = 'Scale' - options['Tonic ({})'.format( - NOTE_NAMES[self.zynseq.libseq.getTonic()])] = 'Tonic' - note = self.zynseq.libseq.getInputRest() - if note < 128: - options['Rest note ({}{})'.format( - NOTE_NAMES[note % 12], note // 12 - 1)] = 'Rest note' - else: - options['Rest note (None)'] = 'Rest note' - - # Pattern Edit - options['> PATTERN EDIT'] = None - # options['Add program change'] = 'Add program change' - if extra_options: - if self.zynseq.libseq.isMidiRecord(): - options['\u2612 Record from MIDI'] = 'Record MIDI' - else: - options['\u2610 Record from MIDI'] = 'Record MIDI' - options['Transpose pattern'] = 'Transpose pattern' - options['Copy pattern'] = 'Copy pattern' - options['Load pattern'] = 'Load pattern' - options['Save pattern'] = 'Save pattern' - options['Clear pattern'] = 'Clear pattern' - options['Export to SMF'] = 'Export to SMF' - options['Export to SMF'] = 'Export to SMF' - - self.zyngui.screens['option'].config( - "Pattern Editor Menu", options, self.menu_cb) - self.zyngui.show_screen('option') - - def toggle_menu(self): - if self.shown: - self.show_menu() - elif self.zyngui.current_screen == "option": - self.close_screen() - - def get_note_from_row(self, row): - return self.keymap[row]["note"] - - def menu_cb(self, option, params): - if params == 'Grid zoom': - self.enable_param_editor(self, 'zoom', {'name': 'Zoom', 'value_min': 1, - 'value_max': 64, 'value_default': 1, 'value': self.zoom}) - elif params == 'Tempo': - self.zyngui.show_screen('tempo') - elif params == 'Arranger': - self.zyngui.show_screen('arranger') - elif params == 'Beats per bar': - self.enable_param_editor(self, 'bpb', {'name': 'Beats per bar', 'value_min': 1, - 'value_max': 64, 'value_default': 4, 'value': self.zynseq.libseq.getBeatsPerBar()}) - - elif params == 'Beats in pattern': - self.enable_param_editor(self, 'bip', {'name': 'Beats in pattern', 'value_min': 1, 'value_max': 64, - 'value_default': 4, 'value': self.zynseq.libseq.getBeatsInPattern()}, self.assert_beats_in_pattern) - elif params == 'Steps per beat': - self.enable_param_editor(self, 'spb', {'name': 'Steps per beat', 'ticks': STEPS_PER_BEAT, - 'value_default': 3, 'value': self.n_steps_beat}, self.assert_steps_per_beat) - elif params == 'Quantization': - self.zynseq.libseq.setQuantizeNotes(not self.zynseq.libseq.getQuantizeNotes()) - elif params == 'Swing Divisor': - self.enable_param_editor(self, 'swing_div', { - 'name': 'Swing Divisor', 'value_min': 1, 'value_max': self.n_steps_beat, 'value_default': 1, 'value': self.zynseq.libseq.getSwingDiv()}) - - elif params == 'Swing Amount': - self.enable_param_editor(self, 'swing_amount', { - 'name': 'Swing Amount', 'value_min': 0, 'value_max': 100, 'value_default': 0, 'value': int(100.0 * self.zynseq.libseq.getSwingAmount())}) - - elif params == 'Time Humanization': - self.enable_param_editor(self, 'human_time', {'name': 'Time Humanization', 'value_min': 0, - 'value_max': 100, 'value_default': 0, 'value': int(500.0 * self.zynseq.libseq.getHumanTime())}) - - elif params == 'Velocity Humanization': - self.enable_param_editor(self, 'human_velo', {'name': 'Velocity Humanization', 'value_min': 0, - 'value_max': 100, 'value_default': 0, 'value': int(self.zynseq.libseq.getHumanVelo())}) - - elif params == 'Note Play Chance': - self.enable_param_editor(self, 'play_chance', {'name': 'Note Play Chance', 'value_min': 0, - 'value_max': 100, 'value_default': 0, 'value': int(100.0 * self.zynseq.libseq.getPlayChance())}) - - elif params == 'Scale': - self.enable_param_editor(self, 'scale', { - 'name': 'Scale', 'labels': self.get_scales(), 'value': self.zynseq.libseq.getScale()}) - elif params == 'Tonic': - self.enable_param_editor(self, 'tonic', { - 'name': 'Tonic', 'labels': NOTE_NAMES, 'value': self.zynseq.libseq.getTonic()}) - elif params == 'Rest note': - labels = ['None'] - for note in range(128): - labels.append("{}{}".format( - NOTE_NAMES[note % 12], note // 12 - 1)) - value = self.zynseq.libseq.getInputRest() + 1 - if value > 128: - value = 0 - self.enable_param_editor( - self, 'rest', {'name': 'Rest', 'labels': labels, 'value': value}) - elif params == 'Add program change': - self.enable_param_editor(self, 'prog_change', { - 'name': 'Program', 'value_max': 128, 'value': self.get_program_change()}, self.add_program_change) - - elif params == 'Record MIDI': - self.toggle_midi_record() - elif params == 'Transpose pattern': - self.enable_param_editor(self, 'transpose', { - 'name': 'Transpose', 'value_min': -1, 'value_max': 1, 'labels': ['down', 'down/up', 'up'], 'value': 0}) - elif params == 'Copy pattern': - self.copy_source = self.pattern - self.enable_param_editor(self, 'copy', {'name': 'Copy pattern to', 'value_min': 1, - 'value_max': zynseq.SEQ_MAX_PATTERNS, 'value': self.pattern, 'nudge_factor': 1}, self.copy_pattern) - elif params == 'Load pattern': - self.zyngui.screens['option'].config_file_list("Load pattern", [ - self.patterns_dpath, self.my_patterns_dpath], "*.zpat", self.load_pattern_file) - self.zyngui.show_screen('option') - elif params == 'Save pattern': - self.zyngui.show_keyboard( - self.save_pattern_file, "pat#{}".format(self.pattern)) - elif params == 'Clear pattern': - self.clear_pattern() - elif params == 'Export to SMF': - self.zyngui.show_keyboard( - self.export_smf, "pat#{}".format(self.pattern)) - elif params == 'SHOW NOTES': - self.display_mode = SHOW_NOTES - self.set_title(f"Pattern {self.pattern}") - self.load_keymap() - self.redraw_pending = 4 - elif params == 'SHOW CC': - self.display_mode = SHOW_CC - self.set_title(f"Pattern {self.pattern} CC") - self.load_keymap() - self.redraw_pending = 4 - - def get_diatonic_chord(self, trigger_note): - chord = [] - match self.chord_mode: - case 2 | 3: - scale_template = SCALES["major"] - case 4 | 5: - scale_template = SCALES["minor"] - case _: - return [] - if self.chord_mode in [3, 5]: - chord_len = 4 - else: - chord_len = 3 - scale_offset = trigger_note % 12 + self.diatonic_scale_tonic - if scale_offset not in scale_template: - return [] # Trigger note not in diatonic scale - note_offset = scale_template.index(scale_offset) - for i in range(chord_len): - chord.append(scale_template[note_offset + 2 * i] - scale_template[note_offset]) - return chord - - def save_pattern_file(self, fname): - self.zynseq.save_pattern( - self.pattern, "{}/{}.zpat".format(self.my_patterns_dpath, fname)) - - def load_pattern_file(self, fname, fpath): - if not self.zynseq.is_pattern_empty(self.pattern): - self.zyngui.show_confirm("Do you want to overwrite pattern '{}'?".format( - self.pattern), self.do_load_pattern_file, fpath) - else: - self.do_load_pattern_file(fpath) - - def do_load_pattern_file(self, fpath): - self.zynseq.load_pattern(self.pattern, fpath) - self.changed = False - self.redraw_pending = 3 - - def clean_pattern_snapshots(self): - self.zynseq.libseq.resetPatternSnapshots() - - # If changed, save snapshot: - # + right now, if now=True - # + force saving ignoring changed flag - # + each loop, if playing - # + each SAVE_SNAPSHOT_DELAY seconds, if stopped - def save_pattern_snapshot(self, now=True, force=False): - if force or self.changed: - if now or (self.playstate != zynseq.SEQ_STOPPED and self.playhead == 0): - self.zynseq.libseq.savePatternSnapshot() - self.changed = False - self.changed_ts = 0 - elif self.playstate == zynseq.SEQ_STOPPED: - ts = datetime.now() - if self.changed_ts: - if (ts - self.changed_ts).total_seconds() > SAVE_SNAPSHOT_DELAY: - self.zynseq.libseq.savePatternSnapshot() - self.changed = False - self.changed_ts = 0 - else: - self.changed_ts = ts - - def undo_pattern(self): - self.save_pattern_snapshot(now=True, force=False) - if self.zynseq.libseq.undoPattern(): - self.redraw_pending = 3 - - def redo_pattern(self): - if not self.changed and self.zynseq.libseq.redoPattern(): - self.redraw_pending = 3 - - def undo_pattern_all(self): - self.save_pattern_snapshot(now=True, force=False) - if self.zynseq.libseq.undoPatternAll(): - self.redraw_pending = 3 - - def redo_pattern_all(self): - if not self.changed and self.zynseq.libseq.redoPatternAll(): - self.redraw_pending = 3 - - def toggle_midi_record(self, midi_record=None): - if midi_record is None: - midi_record = not self.midi_record - self.midi_record = midi_record - self.zynseq.libseq.enableMidiRecord(midi_record) - self.save_pattern_snapshot(now=True, force=False) - - def send_controller_value(self, zctrl): - if zctrl.symbol == 'tempo': - self.zynseq.libseq.setTempo(zctrl.value) - elif zctrl.symbol == 'zoom': - self.set_grid_zoom(zctrl.value) - self.param_editor_zctrl.value = self.zoom - elif zctrl.symbol == 'bpb': - self.zynseq.libseq.setBeatsPerBar(zctrl.value) - elif zctrl.symbol == 'swing_amount': - self.zynseq.libseq.setSwingAmount(zctrl.value/100.0) - elif zctrl.symbol == 'swing_div': - self.zynseq.libseq.setSwingDiv(zctrl.value) - elif zctrl.symbol == 'human_time': - self.zynseq.libseq.setHumanTime(zctrl.value / 500.0) - elif zctrl.symbol == 'human_velo': - self.zynseq.libseq.setHumanVelo(1.0 * zctrl.value) - elif zctrl.symbol == 'play_chance': - self.zynseq.libseq.setPlayChance(zctrl.value / 100.0) - elif zctrl.symbol == 'transpose': - self.transpose(zctrl.value) - zctrl.set_value(0) - elif zctrl.symbol == 'copy': - self.load_pattern(zctrl.value) - elif zctrl.symbol == 'scale': - self.set_scale(zctrl.value) - elif zctrl.symbol == 'tonic': - self.set_tonic(zctrl.value) - elif zctrl.symbol == 'rest': - if zctrl.value == 0: - self.zynseq.libseq.setInputRest(128) - else: - self.zynseq.libseq.setInputRest(zctrl.value - 1) - - # Function to transpose pattern - def transpose(self, offset): - if offset != 0: - self.save_pattern_snapshot(now=True, force=False) - if self.zynseq.libseq.getScale(): - # Change to chromatic scale to transpose - self.zynseq.libseq.setScale(0) - self.load_keymap() - self.zynseq.libseq.transpose(offset) - self.save_pattern_snapshot(now=True, force=True) - self.set_keymap_offset(self.keymap_offset + offset) - self.selected_cell[1] += int(offset) - self.redraw_pending = 3 - self.select_cell() - - # Function to set musical scale - # scale: Index of scale to load - # Returns: name of scale - def set_scale(self, scale): - self.zynseq.libseq.setScale(scale) - self.reload_keymap = True - self.redraw_pending = 3 - - # Function to set tonic (root note) of scale - # tonic: Scale root note - def set_tonic(self, tonic): - self.zynseq.libseq.setTonic(tonic) - self.reload_keymap = True - self.redraw_pending = 3 - - # Function to export pattern to SMF - def export_smf(self, fname): - smf = zynsmf.libsmf.addSmf() - tempo = self.zynseq.libseq.getTempo() - zynsmf.libsmf.addTempo(smf, 0, tempo) - ticks_per_step = zynsmf.libsmf.getTicksPerQuarterNote( - smf) / self.n_steps_beat - for step in range(self.n_steps): - time = int(step * ticks_per_step) - for note in range(128): - duration = self.zynseq.libseq.getNoteDuration(step, note) - if duration == 0.0: - continue - duration = int(duration * ticks_per_step) - velocity = self.zynseq.libseq.getNoteVelocity(step, note) - zynsmf.libsmf.addNote( - smf, 0, time, duration, self.channel, note, velocity) - zynsmf.libsmf.setEndOfTrack(smf, 0, int(self.n_steps * ticks_per_step)) - zynsmf.save(smf, "{}/{}.mid".format(self.my_captures_dpath, fname)) - - # Function to assert steps per beat - def assert_steps_per_beat(self, value): - self.zyngui.show_confirm( - "Changing steps per beat may alter timing and/or lose notes?", self.do_steps_per_beat, value) - - # Function to actually change steps per beat - def do_steps_per_beat(self, value): - self.zynseq.libseq.setStepsPerBeat(value) - self.clean_pattern_snapshots() - self.n_steps_beat = self.zynseq.libseq.getStepsPerBeat() - self.n_steps = self.zynseq.libseq.getSteps() - self.update_geometry() - self.redraw_pending = 4 - - # Function to assert beats in pattern - def assert_beats_in_pattern(self, value): - if self.zynseq.libseq.getLastStep() >= self.zynseq.libseq.getStepsPerBeat() * value: - self.zyngui.show_confirm( - "Reducing beats in pattern will truncate pattern", self.set_beats_in_pattern, value) - else: - self.set_beats_in_pattern(value) - - # Function to assert beats in pattern - def set_beats_in_pattern(self, value): - self.zynseq.libseq.setBeatsInPattern(value) - self.clean_pattern_snapshots() - self.n_steps = self.zynseq.libseq.getSteps() - self.update_geometry() - self.redraw_pending = 4 - - # Function to get the index of the closest steps per beat in array of allowed values - # returns: Index of closest allowed value - def get_steps_per_beat_index(self): - steps_per_beat = self.zynseq.libseq.getStepsPerBeat() - for index in range(len(STEPS_PER_BEAT)): - if STEPS_PER_BEAT[index] >= steps_per_beat: - return index - return index - - # Function to get list of scales - # returns: List of available scales - def get_scales(self): - # Load scales - data = [] - try: - with open(CONFIG_ROOT + "/scales.json") as json_file: - data = json.load(json_file) - except: - logging.warning("Unable to open scales.json") - res = [] - # Look for a custom keymap, defaults to chromatic - custom_keymap = self.get_custom_keymap() - if custom_keymap: - res.append(f"Custom - {custom_keymap}") - else: - res.append(f"Custom - None") - for scale in data: - res.append(scale['name']) - return res - - # Search for a custom map - def get_custom_keymap(self): - synth_proc = self.zyngui.chain_manager.get_synth_processor(self.channel) - if synth_proc: - map_name = None - preset_path = synth_proc.get_presetpath() - try: - with open(CONFIG_ROOT + "/keymaps.json") as json_file: - data = json.load(json_file) - for pat in data: - if pat in preset_path: - map_name = data[pat] - break - if map_name: - keymap_fpath = CONFIG_ROOT + f"/{map_name}.midnam" - if os.path.isfile(keymap_fpath): - return map_name - else: - logging.warning(f"Keymap file {keymap_fpath} doesn't exist.") - except: - logging.warning("Unable to load keymaps.json") - else: - logging.info(f"MIDI channel {self.channel} has not synth processors.") - - # Function to populate keymap array - # returns Name of scale / map - def load_keymap(self): - self.keymap = [] - if self.display_mode == SHOW_CC: - for cc in range(128): - self.keymap.append({ - "note": cc, - "name": str(cc) - }) - return "CC" - - scale = self.zynseq.libseq.getScale() - tonic = self.zynseq.libseq.getTonic() - - # Try to load custom keymap - if scale == 0: - map_name = self.get_custom_keymap() - if map_name: - keymap_fpath = CONFIG_ROOT + f"/{map_name}.midnam" - logging.info( - f"Loading keymap {map_name} for MIDI channel {self.channel}...") - try: - xml = minidom.parse(keymap_fpath) - notes = xml.getElementsByTagName('Note') - for note in notes: - try: - colour = note.attributes['Colour'].value - except: - colour = "white" - self.keymap.append({'note': int(note.attributes['Number'].value), - 'name': note.attributes['Name'].value, - 'colour': colour}) - return map_name - except Exception as e: - logging.error(f"Can't load '{keymap_fpath}' => {e}") - - # Not custom map loaded => Load scale - - # Load specific scale - if scale > 1: - try: - with open(CONFIG_ROOT + "/scales.json") as json_file: - data = json.load(json_file) - if scale <= len(data): - scale -= 1 # Offset by -1 because the 0 is used for custom keymap - for octave in range(0, 9): - for offset in data[scale]['scale']: - note = tonic + offset + octave * 12 - if note > 127: - break - self.keymap.append({"note": note, "name": "{}{}".format(NOTE_NAMES[note % 12], note // 12 - 1)}) - return data[scale]['name'] - except Exception as e: - logging.error(f"Can't load 'scales.json' => {e}") - - # Load chromatic scale - for note in range(0, 128): - new_entry = {"note": note} - key = note % 12 - if key in (1, 3, 6, 8, 10): # Black notes - new_entry.update({"colour": "black"}) - else: - new_entry.update({"colour": "white"}) - if key == 0: # 'C' - new_entry.update({"name": "C{}".format(note // 12 - 1)}) - self.keymap.append(new_entry) - return "Chromatic" - - # Function to handle start of pianoroll drag - def on_pianoroll_press(self, event): - self.swiping = False - self.swipe_step_speed = 0 - self.swipe_row_speed = 0 - self.swipe_step_dir = 0 - self.swipe_row_dir = 0 - self.piano_roll_drag_start = event - self.piano_roll_drag_count = 0 - - # Function to handle pianoroll drag motion - def on_pianoroll_motion(self, event): - if not self.piano_roll_drag_start: - return - self.piano_roll_drag_count += 1 - offset = int(DRAG_SENSIBILITY * (event.y - - self.piano_roll_drag_start.y) / self.row_height) - if offset == 0: - return - self.swiping = True - self.piano_roll_drag_start = event - self.swipe_step_dir = 0 - self.swipe_row_dir = offset - self.set_keymap_offset(self.keymap_offset + offset) - if self.selected_cell[1] < self.keymap_offset: - self.selected_cell[1] = self.keymap_offset - elif self.selected_cell[1] >= self.keymap_offset + int(self.view_rows): - self.selected_cell[1] = self.keymap_offset + int(self.view_rows) - 1 - self.select_cell() - - # Function to handle end of pianoroll drag - def on_pianoroll_release(self, event): - # Play note if not drag action - if self.piano_roll_drag_start and self.piano_roll_drag_count == 0: - row = int( - (self.total_height - self.piano_roll.canvasy(event.y)) / self.row_height) - if row < len(self.keymap): - note = self.keymap[row]['note'] - self.play_note(note) - # Swipe - elif self.swiping: - dts = (event.time - self.piano_roll_drag_start.time)/1000 - self.swipe_nudge(dts) - - self.piano_roll_drag_start = None - self.piano_roll_drag_count = 0 - - # Function to handle mouse wheel over pianoroll - def on_pianoroll_wheel(self, event): - if event.num == 4: - # Scroll up - if self.keymap_offset + self.view_rows < len(self.keymap): - self.set_keymap_offset(self.keymap_offset + 1) - if self.selected_cell[1] < self.keymap_offset: - self.select_cell(self.selected_cell[0], self.keymap_offset) - else: - # Scroll down - if self.keymap_offset > 0: - self.set_keymap_offset(self.keymap_offset - 1) - if self.selected_cell[1] >= self.keymap_offset + self.view_rows: - self.select_cell(self.selected_cell[0], self.keymap_offset + self.view_rows - 1) - - # Function to handle grid mouse down - # event: Mouse event - def on_grid_press(self, event): - if self.param_editor_zctrl: - self.disable_param_editor() - - # Select cell - row = int( - (self.total_height - self.grid_canvas.canvasy(event.y)) / self.row_height) - step = int(self.grid_canvas.canvasx(event.x) / self.step_width) - try: - note = self.keymap[row]['note'] - except: - return - if self.display_mode == SHOW_CC: - start_step = self.zynseq.libseq.getControlStart(step, note) - else: - start_step = self.zynseq.libseq.getNoteStart(step, note) - if start_step >= 0: - step = start_step - if step < 0 or step >= self.n_steps: - return - self.select_cell(step, row) - - # Start drag state variables - self.swiping = False - self.grid_drag_start = event - self.grid_drag_count = 0 - self.swipe_step_speed = 0 - self.swipe_row_speed = 0 - self.swipe_step_dir = 0 - self.swipe_row_dir = 0 - self.drag_note = False - self.drag_velocity = False - self.drag_duration = False - self.drag_start_step = step - if self.display_mode == SHOW_CC: - self.drag_start_velocity = self.zynseq.libseq.getControlValue( - step, note) - self.drag_start_duration = self.zynseq.libseq.getControlDuration( - step, note) - else: - self.drag_start_velocity = self.zynseq.libseq.getNoteVelocity( - step, note) - self.drag_start_duration = self.zynseq.libseq.getNoteDuration( - step, note) - - # Function to handle grid mouse drag - # event: Mouse event - def on_grid_drag(self, event): - if not self.grid_drag_start: - return - if self.grid_drag_count == 0 and abs(event.x - self.grid_drag_start.x) < 2 or abs(event.y - self.grid_drag_start.y) < 2: - # Avoid interpretting tap as drag (especially on V4 touchscreen) - return - self.grid_drag_count += 1 - - if self.drag_note: - step = self.selected_cell[0] - row = self.selected_cell[1] - note = self.keymap[row]['note'] - sel_duration = self.zynseq.libseq.getNoteDuration(step, note) - sel_velocity = self.zynseq.libseq.getNoteVelocity(step, note) - - if self.drag_start_velocity: - # Selected cell has a note so we want to adjust its velocity or duration - if self.display_mode == SHOW_NOTES and not self.drag_velocity and not self.drag_duration and (event.x > (self.drag_start_step + 1) * self.step_width or event.x < self.drag_start_step * self.step_width): - self.drag_duration = True - if not self.drag_duration and not self.drag_velocity and (event.y > self.grid_drag_start.y + self.row_height / 2 or event.y < self.grid_drag_start.y - self.row_height / 2): - self.drag_velocity = True - if self.drag_velocity: - value = (self.grid_drag_start.y - event.y) / self.row_height - velocity = int(self.drag_start_velocity + value * self.height / 100) - if self.display_mode == SHOW_CC: - if 0 <= velocity <= 127: - self.set_velocity_indicator(velocity) - if sel_velocity < 128 and velocity != sel_velocity: - self.zynseq.libseq.setControlValue(step, note, velocity, velocity) - self.draw_cell(step, row) - else: - if 1 <= velocity <= 127: - self.set_velocity_indicator(velocity) - if sel_duration and velocity != sel_velocity: - self.zynseq.libseq.setNoteVelocity(step, note, velocity) - self.draw_cell(step, row) - if self.drag_duration: - duration = int(event.x / self.step_width) - self.drag_start_step - if duration > 0 and duration != sel_duration: - self.add_event(step, row, sel_velocity, duration) - else: - # self.duration = duration - pass - else: - # Clicked on empty cell so want to add a new note by dragging towards the desired cell - # x pos of start of event - x1 = self.selected_cell[0] * self.step_width - x2 = x1 + self.step_width # x pos right of event's first cell - # y pos of bottom of selected row - y1 = self.total_height - self.selected_cell[1] * self.row_height - y2 = y1 - self.row_height # y pos of top of selected row - event_x = self.grid_canvas.canvasx(event.x) - event_y = self.grid_canvas.canvasy(event.y) - if event_x < x1: - self.select_cell(self.selected_cell[0] - 1, None) - elif event_x > x2: - self.select_cell(self.selected_cell[0] + 1, None) - elif event_y > y1: - self.select_cell(None, self.selected_cell[1] - 1) - self.play_note(self.keymap[self.selected_cell[1]]["note"]) - elif event_y < y2: - self.select_cell(None, self.selected_cell[1] + 1) - self.play_note(self.keymap[self.selected_cell[1]]["note"]) - else: - step_offset = int( - DRAG_SENSIBILITY * (self.grid_drag_start.x - event.x) / self.step_width) - row_offset = int(DRAG_SENSIBILITY * (event.y - - self.grid_drag_start.y) / self.row_height) - if step_offset == 0 and row_offset == 0: - if self.grid_drag_count < 2 and (event.time - self.grid_drag_start.time) > 800: - self.drag_note = True - return - self.swiping = True - self.grid_drag_start = event - if step_offset: - self.swipe_step_dir = step_offset - self.set_step_offset(self.step_offset + step_offset) - if row_offset: - self.swipe_row_dir = row_offset - self.set_keymap_offset(self.keymap_offset + row_offset) - if self.selected_cell[1] < self.keymap_offset: - self.selected_cell[1] = self.keymap_offset - elif self.selected_cell[1] >= self.keymap_offset + int(self.view_rows): - self.selected_cell[1] = self.keymap_offset + int(self.view_rows) - 1 - self.select_cell() - - # Function to handle grid mouse release - # event: Mouse event - def on_grid_release(self, event): - # No drag actions - if self.grid_drag_start: - dts = event.time - self.grid_drag_start.time - if self.grid_drag_count == 0: - # Bold click without drag - if (dts) > 800: - if self.edit_mode == EDIT_MODE_NONE: - self.set_edit_mode(EDIT_MODE_SINGLE) - else: - self.set_edit_mode(EDIT_MODE_ALL) - # Short click without drag: Add/remove single note/chord - else: - step = self.selected_cell[0] - row = self.selected_cell[1] - self.toggle_event(step, row) - # End drag action - elif self.drag_note: - if not self.drag_start_velocity: - # Drag drop note - step = self.selected_cell[0] - row = self.selected_cell[1] - # note = self.keymap[row]['note'] - self.toggle_event(step, row) - # Swipe - elif self.swiping: - self.swipe_nudge(dts/1000) - - # Reset drag state variables - self.grid_drag_start = None - self.grid_drag_count = 0 - self.drag_note = False - self.drag_velocity = False - self.drag_duration = False - self.drag_start_step = None - self.drag_start_velocity = None - self.drag_start_duration = None - - def on_gesture(self, gtype, value): - if gtype == MultitouchTypes.GESTURE_H_DRAG: - value = int(-0.1 * value) - self.set_step_offset(self.step_offset + value) - self.select_cell() - elif gtype == MultitouchTypes.GESTURE_V_DRAG: - value = int(0.1 * value) - self.set_keymap_offset(self.keymap_offset + value) - if self.selected_cell[1] < self.keymap_offset: - self.selected_cell[1] = self.keymap_offset - elif self.selected_cell[1] >= self.keymap_offset + int(self.view_rows): - self.selected_cell[1] = self.keymap_offset + int(self.view_rows) - 1 - self.select_cell() - elif gtype in (MultitouchTypes.GESTURE_H_PINCH, MultitouchTypes.GESTURE_V_PINCH): - value = int(0.1 * value) - self.set_grid_zoom(self.zoom + value) - - def plot_zctrls(self): - self.swipe_update() - - def swipe_nudge(self, dts): - try: - kt = 0.5 * min(0.05 * DRAG_SENSIBILITY / dts, 8) - except: - return - self.swipe_step_speed += kt * self.swipe_step_dir - self.swipe_row_speed += kt * self.swipe_row_dir - # logging.debug(f"KT={kt} => SWIPE_STEP_SPEED = {self.swipe_step_speed}, SWIPE_ROW_SPEED = {self.swipe_row_speed}") - - # Update swipe scroll - def swipe_update(self): - select_cell = False - if self.swipe_step_speed: - # logging.debug(f"SWIPE_UPDATE_STEP => {self.swipe_step_speed}") - self.swipe_step_offset += self.swipe_step_speed - self.swipe_step_speed *= self.swipe_friction - if abs(self.swipe_step_speed) < 0.2: - self.swipe_step_speed = 0 - self.swipe_step_offset = 0 - if abs(self.swipe_step_offset) > 1: - self.step_offset += int(self.swipe_step_offset) - self.swipe_step_offset -= int(self.swipe_step_offset) - self.set_step_offset(self.step_offset) - select_cell = True - if self.swipe_row_speed: - # logging.debug(f"SWIPE_UPDATE_ROW => {self.swipe_row_speed}") - self.swipe_row_offset += self.swipe_row_speed - self.swipe_row_speed *= self.swipe_friction - if abs(self.swipe_row_speed) < 0.2: - self.swipe_row_speed = 0 - self.swipe_row_offset = 0 - if abs(self.swipe_row_offset) > 1: - self.keymap_offset += int(self.swipe_row_offset) - self.swipe_row_offset -= int(self.swipe_row_offset) - self.set_keymap_offset(self.keymap_offset) - if self.selected_cell[1] < self.keymap_offset: - self.selected_cell[1] = self.keymap_offset - elif self.selected_cell[1] >= self.keymap_offset + int(self.view_rows): - self.selected_cell[1] = self.keymap_offset + int(self.view_rows) - 1 - select_cell = True - if select_cell: - self.select_cell() - - # Function to adjust velocity indicator - # velocity: Note velocity to indicate - def set_velocity_indicator(self, velocity): - self.velocity_canvas.coords( - "velocityIndicator", 0, 0, self.piano_roll_width * velocity / 127, PLAYHEAD_HEIGHT) - - # Function to toggle note event - # step: step number (column) - # row: keymap index - # Returns: Note if note added else None - def toggle_event(self, step, row): - if step < 0 or step >= self.n_steps or row >= len(self.keymap): - return - note = self.keymap[row]['note'] - if self.display_mode == SHOW_CC: - start_step = self.zynseq.libseq.getControlStart(step, note) - else: - start_step = self.zynseq.libseq.getNoteStart(step, note) - if start_step >= 0: - self.remove_chord(start_step, row) - else: - self.add_chord(step, row, self.velocity, self.duration) - self.select_cell(None, row) - - # Function to remove an event - # step: step number (column) - # row: keymap index - def remove_event(self, step, row): - if row >= len(self.keymap): - return - self.save_pattern_snapshot(now=True, force=False) - note = self.keymap[row]['note'] - if self.display_mode == SHOW_CC: - self.zynseq.libseq.removeControl(step, note) - else: - self.zynseq.libseq.removeNote(step, note) - # Silence note if sounding - self.zynseq.libseq.playNote(note, 0, self.channel) - self.save_pattern_snapshot(now=True, force=True) - self.drawing = True - self.draw_row(row) - self.drawing = False - self.select_cell(step, row) - - # Function to add a note or chord, depending on current chord mode - # step: step number (column) - # row: grid row (MIDI note - note_offset) - # vel: velocity (0-127) - # dur: duration (in steps) - # offset: offset of start of event (0..0.99) - def add_chord(self, step, row, vel, dur, offset=0.0): - if self.display_mode == SHOW_CC: - self.add_event(step, row, vel, dur, offset) - return - match self.chord_mode: - case 0: - # Single note entry - chord = [0] - case 1: - # Chord entry mode - chord = CHORDS[self.chord_type][1] - case _: - # Diatonic chord entry mode - chord = self.get_diatonic_chord(row) - for note_offset in chord: - if self.add_event(step, row + note_offset, vel, dur, offset): - self.play_note(self.keymap[row + note_offset]['note']) - - # Function to remove a note or chord, depending on current chord mode - # step: step number (column) - # note: MIDI note (0-127) - # vel: velocity (0-127) - # dur: duration (in steps) - # offset: offset of start of event (0..0.99) - def remove_chord(self, step, note): - if self.display_mode == SHOW_CC: - self.remove_event(step, note) - match self.chord_mode: - case 0: - # Single note entry - chord = [0] - case 1: - # Chord entry mode - chord = CHORDS[self.chord_type][1] - case _: - # Diatonic chord entry mode - chord = self.get_diatonic_chord(note) - for offset in chord: - self.remove_event(step, note + offset) - - # Function to add an event - # step: step number (column) - # row: keymap index - # vel: velocity (0-127) - # dur: duration (in steps) - # offset: offset of start of event (0..0.99) - def add_event(self, step, row, vel, dur, offset=0.0): - self.save_pattern_snapshot(now=True, force=False) - note = self.keymap[row]["note"] - if note > 127: - return False - if self.display_mode == SHOW_CC: - self.zynseq.libseq.addControl(step, note, vel, vel, dur, offset) - else: - self.zynseq.libseq.addNote(step, note, vel, dur, offset) - self.save_pattern_snapshot(now=True, force=True) - self.drawing = True - self.draw_row(row) - self.drawing = False - self.select_cell(step, row) - return True - - # Function to draw a grid row - # row: Row number (keymap index) - # colour: Black, white or None (default) to not care - def draw_row(self, row, white=None): - self.grid_canvas.itemconfig(f"lastnotetext{row}", state="hidden") - for step in range(self.n_steps): - self.draw_cell(step, row, white) - - # Function to get cell coordinates - # col: Column number (step) - # row: Row number (keymap index) - # duration: Duration of cell in steps - # offset: Factor to offset start of note - # return: Coordinates required to draw cell - def get_cell(self, col, row, duration, offset): - x1 = int((col + offset) * self.step_width) + 1 - y1 = self.total_height - (row + 1) * self.row_height + 1 - x2 = x1 + int(self.step_width * duration) - 1 - y2 = y1 + self.row_height - 1 - return [x1, y1, x2, y2] - - # Function to draw a grid cell - # step: Step (column) index - # row: Index of row - # white: True for white notes - def draw_cell(self, step, row, white=None): - # Flush modified flag to avoid refresh redrawing whole grid => Is this OK? - self.zynseq.libseq.isPatternModified() - # Cells are stored in array sequentially: 1st row, 2nd row... - cellIndex = row * self.n_steps + step - if cellIndex >= len(self.cells): - return - note = self.keymap[row]["note"] - cell = self.cells[cellIndex] - if white is None: - if cell: - white = "white" in self.grid_canvas.gettags(cell) - else: - white = True - - if self.display_mode == SHOW_CC: - velocity_colour = self.zynseq.libseq.getControlValue(step, note) - if velocity_colour == 255: - self.grid_canvas.delete(cell) - self.cells[cellIndex] = None - return - velocity_colour += 70 - duration = self.zynseq.libseq.getControlDuration(step, note) - offset = self.zynseq.libseq.getControlOffset(step, note) - else: - velocity_colour = self.zynseq.libseq.getNoteVelocity(step, note) - if 0 < velocity_colour < 128: - velocity_colour += 70 - duration = self.zynseq.libseq.getNoteDuration(step, note) - offset = self.zynseq.libseq.getNoteOffset(step, note) - else: - self.grid_canvas.delete(cell) - self.cells[cellIndex] = None - return - - fill_colour = f"#{velocity_colour:02x}{velocity_colour:02x}{velocity_colour:02x}" - coord = self.get_cell(step, row, duration, offset) - if white: - cell_tags = ("%d,%d" % (step, row), "gridcell", - "step%d" % step, "white") - else: - cell_tags = ("%d,%d" % (step, row), "gridcell", "step%d" % step) - - if cell: - # Update existing cell - self.grid_canvas.itemconfig(cell, fill=fill_colour, tags=cell_tags) - self.grid_canvas.coords(cell, coord) - else: - # Create new cell - cell = self.grid_canvas.create_rectangle( - coord, fill=fill_colour, width=0, tags=cell_tags) - self.cells[cellIndex] = cell - - if step + duration > self.n_steps: - self.grid_canvas.itemconfig( - "lastnotetext%d" % row, text="+%d" % (duration - self.n_steps + step), state="normal") - - # Function to draw grid - def draw_grid(self): - if self.drawing: - return - self.drawing = True - redraw_pending = self.redraw_pending - self.redraw_pending = 0 - - if self.n_steps == 0: - self.drawing = False - return # TODO: Should we clear grid? - - if len(self.cells) != len(self.keymap) * self.n_steps: - redraw_pending = 4 - self.grid_canvas.delete(tkinter.ALL) - self.draw_pianoroll() - self.cells = [None] * len(self.keymap) * self.n_steps - self.play_canvas.coords("playCursor", 1 + self.playhead * self.step_width, - 0, 1 + self.step_width * (self.playhead + 1), PLAYHEAD_HEIGHT) - - grid_font = tkFont.Font( - family=zynthian_gui_config.font_topbar[0], size=self.fontsize_grid) - bnum_font = tkFont.Font( - family=zynthian_gui_config.font_topbar[0], size=PLAYHEAD_HEIGHT-2) - - # Draw cells of grid - # self.grid_canvas.itemconfig("gridcell", fill="black") - if redraw_pending > 3: - # Redraw gridlines - self.grid_canvas.delete("gridline") - self.play_canvas.delete("beatnum") - if self.n_steps_beat: - lh = 128 * self.row_height - 1 - th = int(0.7 * PLAYHEAD_HEIGHT) - for step in range(0, self.n_steps + 1): - xpos = step * self.step_width - if step % self.n_steps_beat == 0: - self.grid_canvas.create_line( - xpos, 0, xpos, lh, fill=GRID_LINE_STRONG, tags="gridline") - if step < self.n_steps: - beatnum = 1 + step // self.n_steps_beat - if beatnum == 1: - anchor = "nw" - else: - anchor = "n" - self.play_canvas.create_text((xpos, -2), text=str( - beatnum), font=bnum_font, anchor=anchor, fill=GRID_LINE_STRONG, tags="beatnum") - - else: - self.grid_canvas.create_line( - xpos, 0, xpos, lh, fill=GRID_LINE_WEAK, tags="gridline") - self.play_canvas.create_line( - xpos, 0, xpos, th, fill=PLAYHEAD_LINE, tags="beatnum") - - if redraw_pending > 1: - # Delete existing note names from piano roll - self.piano_roll.delete("notename") - - if redraw_pending > 2: - row_min = 0 - row_max = len(self.keymap) - else: - row_min = self.selected_cell[1] - row_max = self.selected_cell[1] - - for row in range(row_min, row_max): - # Create last note labels in grid - self.grid_canvas.create_text(self.total_width - self.select_thickness, int(self.row_height * ( - row - 0.5)), state="hidden", tags=(f"lastnotetext{row}", "lastnotetext", "gridcell"), font=grid_font, anchor="e") - - fill = "black" - # Update pianoroll keys - id = f"row{row}" - try: - name = self.keymap[row]["name"] - except: - name = None - if "colour" in self.keymap[row]: - colour = self.keymap[row]["colour"] - elif name and "#" in name: - colour = "black" - else: - colour = "white" - if colour == "black": - fill = "white" - else: - fill = CANVAS_BACKGROUND - self.piano_roll.itemconfig(id, fill=colour) - # name = str(row) - ypos = self.total_height - row * self.row_height - if name: - self.piano_roll.create_text( - (2, ypos - 0.5 * self.row_height), text=name, font=grid_font, anchor="w", fill=fill, tags="notename") - if self.keymap[row]['note'] % 12 == self.zynseq.libseq.getTonic(): - self.grid_canvas.create_line( - 0, ypos, self.total_width, ypos, fill=GRID_LINE_STRONG, tags="gridline") - else: - self.grid_canvas.create_line( - 0, ypos, self.total_width, ypos, fill=GRID_LINE_WEAK, tags="gridline") - # Draw row of note cells - self.draw_row(row, (colour == "white")) - - # Set z-order to allow duration to show - if redraw_pending > 2: - for step in range(self.n_steps): - self.grid_canvas.tag_lower(f"step{step}") - self.select_cell() - self.drawing = False - - # Function to draw pianoroll key outlines (does not fill key colour) - def draw_pianoroll(self): - self.piano_roll.delete(tkinter.ALL) - for row in range(0, len(self.keymap)): - x1 = 0 - y1 = self.total_height - (row + 1) * self.row_height + 1 - x2 = self.piano_roll_width - y2 = y1 + self.row_height - 1 - tags = f"row{row}" - self.piano_roll.create_rectangle( - x1, y1, x2, y2, width=0, tags=tags) - - # Function to set kaymap offset and move grid view accordingly - # offset: Keymap Offset (note at bottom row) - def set_keymap_offset(self, offset=None): - max_keymap_offset = max(0, len(self.keymap) - self.view_rows) - if offset is not None: - self.keymap_offset = int(offset) - if self.keymap_offset > max_keymap_offset: - self.keymap_offset = int(max_keymap_offset) - elif self.keymap_offset < 0: - self.keymap_offset = 0 - ypos = (self.scroll_height - self.keymap_offset * self.row_height) / self.total_height - self.grid_canvas.yview_moveto(ypos) - self.piano_roll.yview_moveto(ypos) - # logging.debug(f"OFFSET: {self.keymap_offset} (keymap length: {len(self.keymap)})") - # logging.debug(f"GRID Y-SCROLL: {ypos}\n\n") - - # Function to set step offset and move grid view accordingly - # offset: Step Offset (step at left column) - def set_step_offset(self, offset=None): - if offset is not None: - self.step_offset = offset - if self.step_offset > self.n_steps - int(self.view_steps): - self.step_offset = self.n_steps - int(self.view_steps) - elif self.step_offset < 0: - self.step_offset = 0 - if self.total_width > 0: - xpos = self.step_offset * self.step_width / self.total_width - else: - xpos = 0 - self.grid_canvas.xview_moveto(xpos) - self.play_canvas.xview_moveto(xpos) - # logging.debug(f"OFFSET: {self.step_offset} (NSTEPS: {self.n_steps}, TOTAL WIDTH: {self.total_width})") - # logging.debug(f"GRID X-SCROLL: {xpos}\n\n") - - def set_grid_zoom(self, new_zoom=0): - # self.selected_cell - # Calculate new cell size - step_width = self.base_step_width + new_zoom - row_height = self.base_row_height + new_zoom - # Check step width limits - if step_width > self.max_step_width: - step_width = self.max_step_width - elif step_width < self.min_step_width: - step_width = self.min_step_width - # Check row height limits - if row_height > self.max_row_height: - row_height = self.max_row_height - elif row_height < self.min_row_height: - row_height = self.min_row_height - # Do nothing if nothing changed - if self.step_width != step_width: - self.step_width = step_width - step_width_changed = True - else: - step_width_changed = False - if self.row_height != row_height: - self.row_height = row_height - row_height_changed = True - else: - row_height_changed = False - if not step_width_changed and not row_height_changed: - return False - # Adjust real zoom value - hzoom = self.step_width - self.base_step_width - vzoom = self.row_height - self.base_row_height - if abs(hzoom) > abs(vzoom): - self.zoom = hzoom - else: - self.zoom = vzoom - # Recalculate geometry parameters and scaling factor - w = self.total_width - h = self.total_height - self.update_geometry() - xscale = self.total_width / w - yscale = self.total_height / h - # Scale canvas - self.grid_canvas.scale("all", 0, 0, xscale, yscale) - self.play_canvas.scale("all", 0, 0, xscale, 1.0) - self.piano_roll.scale("all", 0, 0, 1.0, yscale) - # Update grid position - if step_width_changed: - self.set_step_offset() - if row_height_changed: - self.set_keymap_offset() - self.view_rows = self.grid_height / self.row_height - self.view_steps = self.grid_width / self.step_width - return True - - def reset_grid_zoom(self): - self.zoom = 0 - self.view_rows = DEFAULT_VIEW_ROWS - self.view_steps = DEFAULT_VIEW_STEPS - self.row_height = self.base_row_height - self.step_width = self.base_step_width - w = self.total_width - h = self.total_height - self.update_geometry() - xscale = self.total_width / w - yscale = self.total_height / h - self.grid_canvas.scale("all", 0, 0, xscale, yscale) - self.play_canvas.scale("all", 0, 0, xscale, 1.0) - self.piano_roll.scale("all", 0, 0, 1.0, yscale) - self.set_keymap_offset() - self.set_step_offset() - # if self.edit_mode == EDIT_MODE_ZOOM: - # self.edit_mode = EDIT_MODE_NONE - - # Function to calculate variable gemoetry parameters - def update_geometry(self): - # Y-axis calculations - self.total_height = 128 * self.row_height - self.scroll_height = self.total_height - self.grid_height - self.min_row_height = self.grid_height // 36 - self.max_row_height = self.grid_height // 6 - - # X-axis calculations - self.total_width = self.n_steps * self.step_width - self.min_step_width = self.grid_width // 64 - try: - self.min_step_width = max( - self.min_step_width, self.grid_width // self.n_steps) - except: - pass - self.max_step_width = self.grid_width // 8 - - # Font size calculation - self.fontsize_grid = self.row_height // 2 - if self.fontsize_grid > 20: - self.fontsize_grid = 20 # Ugly font scale limiting - - # Update scrollregion in canvas - if self.total_width > 0: - self.grid_canvas.config(scrollregion=( - 0, 0, self.total_width, self.total_height)) - self.piano_roll.config(scrollregion=( - 0, 0, self.piano_roll_width, self.total_height)) - self.play_canvas.config(scrollregion=( - 0, 0, self.total_width, PLAYHEAD_HEIGHT)) - # logging.debug(f"GRID SCROLLREGION: {self.total_width} x {self.total_height}") - - # Function to update selectedCell - # step: Step (column) of selected cell (Optional - default to reselect current column) - # row: Index of keymap to select (Optional - default to reselect current row) Maybe outside visible range to scroll display - def select_cell(self, step=None, row=None): - if not self.keymap: - return - # Check row boundaries - if row is None: - row = self.selected_cell[1] - if row < 0: - row = 0 - elif row >= len(self.keymap): - row = len(self.keymap) - 1 - else: - row = int(row) - # Check keymap offset - if row >= self.keymap_offset + self.view_rows: - # Note is off top of display - self.set_keymap_offset(row - self.view_rows + 1) - elif row < self.keymap_offset: - # Note is off bottom of display - self.set_keymap_offset(row) - # if redraw and self.redraw_pending < 1: - # self.redraw_pending = 3 - note = self.keymap[row]['note'] - - # Check column boundaries - if step is None: - step = self.selected_cell[0] - if step < 0: - step = 0 - elif step >= self.n_steps: - step = self.n_steps - 1 - else: - step = int(step) - # Skip hidden (overlapping) cells - for previous in range(step - 1, -1, -1): - if self.display_mode == SHOW_CC: - prev_duration = ceil(self.zynseq.libseq.getControlDuration(previous, note)) - else: - prev_duration = ceil(self.zynseq.libseq.getNoteDuration(previous, note)) - if not prev_duration: - continue - if prev_duration > step - previous: - if step > self.selected_cell[0]: - step = previous + prev_duration - else: - step = previous - break - # Re-check column boundaries - if step < 0: - step = 0 - elif step >= self.n_steps: - step = self.n_steps - 1 - # Check step offset - if step >= self.step_offset + int(self.view_steps): - # Step is off right of display - self.set_step_offset(step - int(self.view_steps) + 1) - elif step < self.step_offset: - # Step is off left of display - self.set_step_offset(step) - self.selected_cell = [step, row] - # Duration & velocity - if self.display_mode == SHOW_CC: - offset = self.zynseq.libseq.getControlOffset(step, note) - velocity = self.zynseq.libseq.getControlValue(step, note) - if velocity > 128: - velocity = self.velocity - duration = self.duration - else: - duration = self.zynseq.libseq.getControlDuration(step, note) - else: - duration = self.zynseq.libseq.getNoteDuration(step, note) - offset = self.zynseq.libseq.getNoteOffset(step, note) - if duration: - velocity = self.zynseq.libseq.getNoteVelocity(step, note) - else: - duration = self.duration - velocity = self.velocity - self.set_velocity_indicator(velocity) - # Position selector cell-frame - coord = self.get_cell(step, row, duration, offset) - coord[0] -= 1 - coord[1] -= 1 - cell = self.grid_canvas.find_withtag("selection") - if not cell: - cell = self.grid_canvas.create_rectangle( - coord, fill="", outline=SELECT_BORDER, width=self.select_thickness, tags="selection") - else: - self.grid_canvas.coords(cell, coord) - self.grid_canvas.tag_raise(cell) - - # Function to clear a pattern - def clear_pattern(self, params=None): - self.zyngui.show_confirm( - f"Clear pattern {self.pattern}?", self.do_clear_pattern) - - # Function to actually clear pattern - def do_clear_pattern(self, params=None): - self.save_pattern_snapshot(now=True, force=False) - self.zynseq.libseq.clear() - self.save_pattern_snapshot(now=True, force=True) - self.redraw_pending = 3 - self.select_cell() - if self.zynseq.libseq.getPlayState(self.bank, self.sequence, 0) != zynseq.SEQ_STOPPED: - self.zynseq.libseq.sendMidiCommand( - 0xB0 | self.channel, 123, 0) # All notes off - - # Function to copy pattern - def copy_pattern(self, value): - if self.zynseq.libseq.getLastStep() == -1: - self.do_copy_pattern(value) - else: - self.zyngui.show_confirm("Overwrite pattern {} with content from pattern {}?".format(value, self.copy_source), - self.do_copy_pattern, value) - self.load_pattern(self.copy_source) - - # Function to cancel copy pattern operation - def cancel_copy(self): - self.load_pattern(self.copy_source) - - # Function to actually copy pattern - def do_copy_pattern(self, dest_pattern): - self.zynseq.libseq.copyPattern(self.copy_source, dest_pattern) - self.pattern = dest_pattern - self.load_pattern(self.pattern) - self.copy_source = self.pattern - # TODO: Update arranger when it is refactored - # self.zyngui.screen['arranger'].pattern = self.pattern - # self.zyngui.screen['arranger'].pattern_canvas.itemconfig("patternIndicator", text="{}".format(self.pattern)) - - # Function to get program change at start of pattern - # returns: Program change number (1..128) or 0 for none - def get_program_change(self): - program = self.zynseq.libseq.getProgramChange(0) + 1 - if program > 128: - program = 0 - return program - - # Function to add program change at start of pattern - def add_program_change(self, value): - self.zynseq.libseq.addProgramChange(0, value) - - # Function to load new pattern - # index: Pattern index - def load_pattern(self, index): - # Save zoom value and vertical position in pattern object - self.zynseq.libseq.setRefNote(int(self.keymap_offset)) - self.zynseq.libseq.setPatternZoom(self.zoom) - - # Load requested pattern - if self.bank == 0 and self.sequence == 0: - self.zynseq.libseq.setChannel( - self.bank, self.sequence, 0, self.channel) - self.zynseq.libseq.selectPattern(index) - self.pattern = index - - n_steps = self.zynseq.libseq.getSteps() - n_steps_beat = self.zynseq.libseq.getStepsPerBeat() - keymap_len = len(self.keymap) - self.load_keymap() - if n_steps != self.n_steps or n_steps_beat != self.n_steps_beat or len(self.keymap) != keymap_len: - self.n_steps = n_steps - self.n_steps_beat = n_steps_beat - self.step_offset = 0 - self.update_geometry() - self.redraw_pending = 4 - keymap_len = len(self.keymap) - else: - self.redraw_pending = 3 - - if self.selected_cell[0] >= n_steps: - self.selected_cell[0] = int(n_steps) - 1 - self.keymap_offset = int(self.zynseq.libseq.getRefNote()) - if self.keymap_offset >= keymap_len: - self.keymap_offset = max(0, int((keymap_len - self.view_rows) / 2)) - self.selected_cell[1] = int(self.keymap_offset + self.view_rows / 2) - if self.duration > n_steps: - self.duration = 1 - self.draw_grid() - self.select_cell() - self.set_keymap_offset() - self.play_canvas.coords( - "playCursor", 1, 0, 1 + self.step_width, PLAYHEAD_HEIGHT) - self.set_title("Pattern {}".format(self.pattern)) - self.set_grid_zoom(self.zynseq.libseq.getPatternZoom()) - - # Function to refresh status - def refresh_status(self): - super().refresh_status() - self.playstate = self.zynseq.libseq.getSequenceState( - self.bank, self.sequence) & 0xff - step = self.zynseq.libseq.getPatternPlayhead() - if self.playhead != step: - self.playhead = step - self.play_canvas.coords("playCursor", 1 + self.playhead * self.step_width, - 0, 1 + self.step_width * (self.playhead + 1), PLAYHEAD_HEIGHT) - if (self.reload_keymap or self.zynseq.libseq.isPatternModified()) and self.redraw_pending < 3: - self.redraw_pending = 3 - if self.reload_keymap: - self.load_keymap() - self.reload_keymap = False - self.set_keymap_offset() - if self.redraw_pending: - self.draw_grid() - if not self.drawing: - pending_rows = set() - while not self.rows_pending.empty(): - pending_rows.add(self.rows_pending.get_nowait()) - while len(pending_rows): - self.draw_row(pending_rows.pop(), None) - self.save_pattern_snapshot(now=False, force=False) - - # Function to handle MIDI notes (only used to refresh screen - actual MIDI input handled by lib) - def midi_note_on(self, note): - self.rows_pending.put_nowait(note) - - def midi_note_off(self, note): - if self.playstate == zynseq.SEQ_STOPPED: - self.save_pattern_snapshot(now=True, force=True) - else: - self.changed = True - self.rows_pending.put_nowait(note) - - # Function to enable note duration/velocity direct edit mode - # mode: Edit mode to enable [EDIT_MODE_NONE | EDIT_MODE_SINGLE | EDIT_MODE_ALL] - def set_edit_mode(self, mode): - self.edit_mode = mode - if self.display_mode == SHOW_CC: - self.edit_param = EDIT_PARAM_VEL - if mode == EDIT_MODE_SINGLE: - self.set_title("Note Parameters", zynthian_gui_config.color_header_bg, - zynthian_gui_config.color_panel_tx) - self.set_edit_title() - elif mode == EDIT_MODE_ALL: - self.set_title("Note Parameters ALL", zynthian_gui_config.color_header_bg, - zynthian_gui_config.color_panel_tx) - self.set_edit_title() - elif self.edit_mode == EDIT_MODE_ZOOM: - self.set_title("Grid zoom", zynthian_gui_config.color_header_bg, - zynthian_gui_config.color_panel_tx) - elif self.edit_mode == EDIT_MODE_HISTORY: - self.set_title("Undo/Redo", zynthian_gui_config.color_header_bg, - zynthian_gui_config.color_panel_tx) - self.init_buttonbar( - [("ARROW_LEFT", "<< undo"), ("ARROW_RIGHT", "redo >>")]) - elif self.chord_mode: - self.set_title(f"Pattern {self.pattern} [Chord Entry]", - zynthian_gui_config.color_panel_tx, zynthian_gui_config.color_header_bg) - self.init_buttonbar() - else: - self.set_title(f"Pattern {self.pattern}", - zynthian_gui_config.color_panel_tx, zynthian_gui_config.color_header_bg) - self.init_buttonbar() - - def set_edit_title(self): - step = self.selected_cell[0] - note = self.get_note_from_row(self.selected_cell[1]) - delta = "1" - zynpot = 2 - if self.edit_mode == EDIT_MODE_ALL: - if self.edit_param == EDIT_PARAM_DUR: - delta = "0.1" - zynpot = 1 - self.set_title("Duration ALL") - elif self.edit_param == EDIT_PARAM_VEL: - self.set_title("Velocity ALL") - elif self.edit_param == EDIT_PARAM_STUT_CNT: - self.set_title("Stutter count ALL") - elif self.edit_param == EDIT_PARAM_STUT_DUR: - self.set_title("Stutter duration ALL") - else: - if self.edit_param == EDIT_PARAM_DUR: - sel_duration = self.zynseq.libseq.getNoteDuration(step, note) - if sel_duration > 0: - duration = sel_duration - else: - duration = self.duration - self.set_title(f"Duration: {duration:0.1f} steps") - delta = "0.1" - zynpot = 1 - elif self.edit_param == EDIT_PARAM_VEL: - if self.display_mode == SHOW_CC: - sel_velocity = self.zynseq.libseq.getControlValue(step, note) - if sel_velocity < 128: - velocity = sel_velocity - else: - velocity = self.velocity - self.set_title(f"CC Value: {velocity}") - else: - sel_velocity = self.zynseq.libseq.getNoteVelocity(step, note) - if sel_velocity > 0: - velocity = sel_velocity - else: - velocity = self.velocity - self.set_title(f"Velocity: {velocity}") - elif self.edit_param == EDIT_PARAM_OFFSET: - if self.display_mode == SHOW_CC: - self.set_title( - f"Offset: {round(100 * self.zynseq.libseq.getControlOffset(step, note))}%") - else: - self.set_title( - f"Offset: {round(100 * self.zynseq.libseq.getNoteOffset(step, note))}%") - elif self.edit_param == EDIT_PARAM_STUT_CNT: - self.set_title( - f"Stutter count: {self.zynseq.libseq.getStutterCount(step, note)}") - elif self.edit_param == EDIT_PARAM_STUT_DUR: - self.set_title( - f"Stutter duration: {self.zynseq.libseq.getStutterDur(step, note)}") - elif self.edit_param == EDIT_PARAM_CHANCE: - self.set_title( - f"Play chance: {self.zynseq.libseq.getNotePlayChance(step, note)}%") - elif self.edit_param == EDIT_PARAM_CHORD_MODE: - self.set_title( - f"Chord mode: {CHORD_MODES[self.chord_mode]}") - elif self.edit_param == EDIT_PARAM_CHORD_TYPE: - if self.chord_mode == 0: - self.set_title( - f"Chord type: Single note") - elif self.chord_mode == 1: - self.set_title( - f"Chord type: {CHORDS[self.chord_type][0]}") - else: - self.set_title( - f"Diatonic key: {NOTE_NAMES[self.diatonic_scale_tonic]}") - - self.init_buttonbar([(f"ZYNPOT {zynpot},-1", f"-{delta}"), (f"ZYNPOT {zynpot},+1", f"+{delta}"), - ("ZYNPOT 3,-1", "PREV\nPARAM"), ("ZYNPOT 3,+1", "NEXT\nPARAM"), (3, "OK")]) - - # Function to handle zynpots value change - # i: Zynpot index [0..n] - # dval: Current value of zyncoder - def zynpot_cb(self, i, dval): - if super().zynpot_cb(i, dval): - return - - if i == self.ctrl_order[0] and zynthian_gui_config.transport_clock_source <= 1: - self.zynseq.update_tempo() - self.zynseq.nudge_tempo(dval) - self.set_title("Tempo: {:.1f}".format( - self.zynseq.get_tempo()), None, None, 2) - - elif i == self.ctrl_order[1]: - if self.edit_mode == EDIT_MODE_SINGLE: - if self.edit_param == EDIT_PARAM_DUR: - step = self.selected_cell[0] - index = self.selected_cell[1] - note = self.keymap[index]['note'] - sel_duration = self.zynseq.libseq.getNoteDuration(step, note) - if sel_duration > 0: - duration = sel_duration - else: - duration = self.duration - duration += 0.1 * dval - max_duration = self.n_steps - if duration > max_duration or duration < 0.05: - return - if sel_duration: - sel_velocity = self.zynseq.libseq.getNoteVelocity(step, note) - sel_offset = self.zynseq.libseq.getNoteOffset(step, note) - self.add_chord(step, self.selected_cell[1], sel_velocity, duration, sel_offset) - else: - self.duration = duration - self.select_cell() - self.set_edit_title() - elif self.edit_mode == EDIT_MODE_ALL: - if self.edit_param == EDIT_PARAM_DUR: - self.zynseq.libseq.changeDurationAll(dval * 0.1) - self.redraw_pending = 3 - else: - self.set_grid_zoom(self.zoom + dval) - # patnum = self.pattern + dval - # if patnum > 0: - # self.pattern = patnum - # self.load_pattern(self.pattern) - - elif i == self.ctrl_order[2]: - if self.edit_mode == EDIT_MODE_SINGLE: - step = self.selected_cell[0] - index = self.selected_cell[1] - note = self.keymap[index]['note'] - sel_duration = self.zynseq.libseq.getNoteDuration(step, note) - if self.edit_param == EDIT_PARAM_DUR: - if sel_duration > 0: - duration = sel_duration - else: - duration = self.duration - duration += dval - max_duration = self.n_steps - if duration > max_duration or duration < 0.05: - return - if sel_duration: - sel_velocity = self.zynseq.libseq.getNoteVelocity(step, note) - sel_offset = self.zynseq.libseq.getNoteOffset(step, note) - self.add_event(step, index, sel_velocity,duration, sel_offset) - else: - self.duration = duration - self.select_cell() - elif self.edit_param == EDIT_PARAM_VEL: - no_sel = True - if self.display_mode == SHOW_CC: - sel_velocity = self.zynseq.libseq.getControlValue(step, note) - if sel_velocity < 128: - velocity = sel_velocity - no_sel = False - else: - velocity = self.velocity - elif sel_duration: - sel_velocity = self.zynseq.libseq.getNoteVelocity(step, note) - velocity = sel_velocity - no_sel = False - else: - velocity = self.velocity - velocity += dval - if self.display_mode == SHOW_CC: - if velocity > 127 or velocity < 0: - return - else: - if velocity > 127 or velocity < 1: - return - self.set_velocity_indicator(velocity) - if no_sel: - self.velocity = velocity - self.select_cell() - else: - if self.display_mode == SHOW_CC: - self.zynseq.libseq.setControlValue(step, note, velocity, velocity) - self.draw_cell(step, index) - elif sel_duration: - self.zynseq.libseq.setNoteVelocity(step, note, velocity) - self.draw_cell(step, index) - elif self.edit_param == EDIT_PARAM_OFFSET: - if self.display_mode == SHOW_CC: - val = round( - 100 * self.zynseq.libseq.getControlOffset(step, note)) + dval - else: - val = round( - 100 * self.zynseq.libseq.getNoteOffset(step, note)) + dval - if val > 99: - val = 99 - elif val < 0: - val = 0 - if self.display_mode == SHOW_CC: - self.zynseq.libseq.setControlOffset(step, note, val/100.0) - else: - self.zynseq.libseq.setNoteOffset(step, note, val/100.0) - self.draw_row(index) - elif self.edit_param == EDIT_PARAM_STUT_CNT: - val = self.zynseq.libseq.getStutterCount(step, note) + dval - if val < 0: - val = 0 - self.zynseq.libseq.setStutterCount(step, note, val) - self.draw_cell(step, note - self.keymap_offset) - elif self.edit_param == EDIT_PARAM_STUT_DUR: - val = self.zynseq.libseq.getStutterDur(step, note) + dval - if val < 1: - val = 1 - self.zynseq.libseq.setStutterDur(step, note, val) - self.draw_cell(step, note - self.keymap_offset) - elif self.edit_param == EDIT_PARAM_CHANCE: - val = self.zynseq.libseq.getNotePlayChance(step, note) + dval - if val < 0: - val = 0 - elif val > 100: - val = 100 - self.zynseq.libseq.setNotePlayChance(step, note, val) - self.draw_cell(step, note - self.keymap_offset) - elif self.edit_param == EDIT_PARAM_CHORD_MODE: - self.chord_mode += dval - if self.chord_mode < 0: - self.chord_mode = 0 - elif self.chord_mode > 5: - self.chord_mode = 5 - elif self.edit_param == EDIT_PARAM_CHORD_TYPE: - if self.chord_mode == 1: - self.chord_type += dval - if self.chord_type < 0: - self.chord_type = 0 - if self.chord_type > len(CHORDS) - 1: - self.chord_type = len(CHORDS) - 1 - elif self.chord_mode > 1: - self.diatonic_scale_tonic += dval - self.diatonic_scale_tonic %= len(NOTE_NAMES) - self.set_edit_title() - elif self.edit_mode == EDIT_MODE_ALL: - if self.edit_param == EDIT_PARAM_DUR: - if dval > 0: - self.zynseq.libseq.changeDurationAll(1) - if dval < 0: - self.zynseq.libseq.changeDurationAll(-1) - self.redraw_pending = 3 - elif self.edit_param == EDIT_PARAM_VEL: - self.zynseq.libseq.changeVelocityAll(dval) - self.redraw_pending = 3 - elif self.edit_param == EDIT_PARAM_STUT_CNT: - self.zynseq.libseq.changeStutterCountAll(dval) - self.redraw_pending = 3 - elif self.edit_param == EDIT_PARAM_STUT_DUR: - self.zynseq.libseq.changeStutterDurAll(dval) - self.redraw_pending = 3 - else: - self.select_cell(None, self.selected_cell[1] - dval) - - elif i == self.ctrl_order[3]: - if self.edit_mode == EDIT_MODE_SINGLE or self.edit_mode == EDIT_MODE_ALL: - self.edit_param += dval - if self.display_mode == SHOW_CC: - if self.edit_param < EDIT_PARAM_VEL: - self.edit_param = EDIT_PARAM_VEL - if self.edit_param > EDIT_PARAM_OFFSET: - self.edit_param = EDIT_PARAM_OFFSET - else: - if self.edit_param < 0: - self.edit_param = 0 - if self.edit_param > EDIT_PARAM_LAST: - self.edit_param = EDIT_PARAM_LAST - if self.edit_param == EDIT_PARAM_CHORD_TYPE and self.chord_mode == 0: - self.edit_param = EDIT_PARAM_CHORD_MODE - self.set_edit_title() - elif self.edit_mode == EDIT_MODE_ZOOM: - self.set_grid_zoom(self.zoom + dval) - elif self.edit_mode == EDIT_MODE_HISTORY: - if dval > 0: - self.redo_pattern() - else: - self.undo_pattern() - else: - self.select_cell(self.selected_cell[0] + dval, None) - - # Function to handle SELECT button press - # type: Button press duration ["S"=Short, "B"=Bold, "L"=Long] - def switch_select(self, type='S'): - if super().switch_select(type): - return - if type == "S": - if self.edit_mode == EDIT_MODE_NONE: - self.toggle_event( self.selected_cell[0], self.selected_cell[1]) - else: - self.set_edit_mode(EDIT_MODE_NONE) - elif type == "B": - if self.edit_mode == EDIT_MODE_NONE: - self.set_edit_mode(EDIT_MODE_SINGLE) - elif self.edit_mode == EDIT_MODE_SINGLE and self.display_mode == SHOW_NOTES: - self.set_edit_mode(EDIT_MODE_ALL) - - # Function to handle switch press - # i: Switch index [0=Layer, 1=Back, 2=Snapshot, 3=Select] - # type: Press type ["S"=Short, "B"=Bold, "L"=Long] - # returns True if action fully handled or False if parent action should be triggered - def switch(self, i, type): - if i == 0 and type == "S": - self.show_menu() - return True - elif i == 1: - if type == 'B': - self.set_edit_mode(EDIT_MODE_HISTORY) - return True - elif i == 2: - if type == 'S': - self.cuia_toggle_play() - return True - elif type == 'B': - self.cuia_toggle_record() - return True - elif type == "P": - return False - return False - - # Function to handle BACK button - def back_action(self): - if self.edit_mode == EDIT_MODE_NONE: - return super().back_action() - self.set_edit_mode(EDIT_MODE_NONE) - return True - - # CUIA Actions - - # Function to handle CUIA ARROW_RIGHT - def arrow_right(self): - if self.zyngui.alt_mode or self.edit_mode == EDIT_MODE_HISTORY: - self.redo_pattern() - else: - self.zynpot_cb(self.ctrl_order[3], 1) - - # Function to handle CUIA ARROW_LEFT - def arrow_left(self): - if self.zyngui.alt_mode or self.edit_mode == EDIT_MODE_HISTORY: - self.undo_pattern() - else: - self.zynpot_cb(self.ctrl_order[3], -1) - - # Function to handle CUIA ARROW_UP - def arrow_up(self): - if self.param_editor_zctrl: - self.zynpot_cb(self.ctrl_order[3], 1) - elif self.edit_mode: - self.zynpot_cb(self.ctrl_order[2], 1) - elif self.zyngui.alt_mode: - self.redo_pattern_all() - else: - self.zynpot_cb(self.ctrl_order[2], -1) - - # Function to handle CUIA ARROW_DOWN - def arrow_down(self): - if self.param_editor_zctrl: - self.zynpot_cb(self.ctrl_order[3], -1) - elif self.edit_mode: - self.zynpot_cb(self.ctrl_order[2], -1) - elif self.zyngui.alt_mode: - self.undo_pattern_all() - else: - self.zynpot_cb(self.ctrl_order[2], 1) - - def start_playback(self): - # Set to start of pattern - work around for timebase issue in library. - self.zynseq.libseq.setPlayPosition(self.bank, self.sequence, 0) - self.zynseq.libseq.setPlayState( - self.bank, self.sequence, zynseq.SEQ_STARTING) - - def stop_playback(self): - self.zynseq.libseq.setPlayState( - self.bank, self.sequence, zynseq.SEQ_STOPPED) - - def toggle_playback(self): - if self.zynseq.libseq.getPlayState(self.bank, self.sequence) == zynseq.SEQ_STOPPED: - self.start_playback() - else: - self.stop_playback() - - def get_playback_status(self): - return self.zynseq.libseq.getPlayState(self.bank, self.sequence) - - def status_short_touch_action(self): - self.toggle_playback() - - # ------------------------------------------------------------------------- - # CUIA & LEDs methods - # ------------------------------------------------------------------------- - - def cuia_toggle_record(self, params=None): - self.toggle_midi_record() - return True - - def cuia_stop(self, params=None): - self.stop_playback() - return True - - def cuia_toggle_play(self, params=None): - self.toggle_playback() - return True - - def update_wsleds(self, leds): - wsl = self.zyngui.wsleds - # REC button: - if self.zynseq.libseq.isMidiRecord(): - wsl.set_led(leds[1], wsl.wscolor_red) - # BACK button - wsl.set_led(leds[8], wsl.wscolor_active2) - else: - wsl.set_led(leds[1], wsl.wscolor_active2) - # STOP button - wsl.set_led(leds[2], wsl.wscolor_active2) - # PLAY button: - pb_status = self.zyngui.screens['pattern_editor'].get_playback_status() - if pb_status == zynseq.SEQ_PLAYING: - wsl.set_led(leds[3], wsl.wscolor_green) - elif pb_status in (zynseq.SEQ_STARTING, zynseq.SEQ_RESTARTING): - wsl.set_led(leds[3], wsl.wscolor_yellow) - elif pb_status in (zynseq.SEQ_STOPPING, zynseq.SEQ_STOPPINGSYNC): - wsl.set_led(leds[3], wsl.wscolor_red) - elif pb_status == zynseq.SEQ_STOPPED: - wsl.set_led(leds[3], wsl.wscolor_active2) - # Arrow buttons - if self.zyngui.alt_mode and not (self.param_editor_zctrl or self.edit_mode): - wsl.set_led(leds[4], wsl.wscolor_active2) - wsl.set_led(leds[5], wsl.wscolor_active2) - wsl.set_led(leds[6], wsl.wscolor_active2) - wsl.set_led(leds[7], wsl.wscolor_active2) - -# ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_gui_preset.py b/zyngui/zynthian_gui_preset.py index 9fac7be20..65d07a87e 100644 --- a/zyngui/zynthian_gui_preset.py +++ b/zyngui/zynthian_gui_preset.py @@ -128,7 +128,6 @@ def show_preset_options(self): except: preset = None title = "Preset Options" - pass if preset: if self.processor.engine.is_preset_fav(preset): options["\u2612 Favourite"] = [preset, ["Remove from favorites list", "favorite_remove.png"]] @@ -170,7 +169,7 @@ def show_menu(self): def toggle_menu(self): if self.shown: self.show_menu() - elif self.zyngui.current_screen == "option": + elif self.zyngui.get_current_screen() == "option": self.close_screen() def preset_options_cb(self, option, preset): diff --git a/zyngui/zynthian_gui_processor_options.py b/zyngui/zynthian_gui_processor_options.py index 2f3f3e310..8a46700b5 100644 --- a/zyngui/zynthian_gui_processor_options.py +++ b/zyngui/zynthian_gui_processor_options.py @@ -24,9 +24,12 @@ # ****************************************************************************** import logging +import random +from copy import copy # Zynthian specific modules from zyngui.zynthian_gui_selector import zynthian_gui_selector +import zynautoconnect # ------------------------------------------------------------------------------ # Zynthian processor Options GUI Class @@ -41,46 +44,59 @@ def __init__(self): def reset(self): self.index = 0 - self.chain_id = None - self.chain = None self.processor = None + self.last_random = {} def fill_list(self): self.list_data = [] - self.list_data.append((self.show_details, None, "Info")) - - if self.can_move_upchain(): - self.list_data.append((self.move_upchain, None, "Move up chain")) - if self.can_move_downchain(): - self.list_data.append( - (self.move_downchain, None, "Move down chain")) - - if self.processor.type == "MIDI Synth": - eng_options = self.processor.engine.get_options() - if eng_options['replace']: + self.list_data.append((None, None, "> Manage this processor")) + # Move processor + if self.processor.type not in ("MIDI Synth", "Audio Generator") and self.processor.chain is not None: + if self.processor.chain.get_processor_count(self.processor.type) > 1: + self.list_data.append((self.start_move, None, "Move")) + + # Replace and Remove processor + if self.processor.eng_code not in ("MI", "MR"): + if self.processor.type == "MIDI Synth": + eng_options = self.processor.engine.get_options() + if eng_options['replace']: + self.list_data.append((self.replace, None, f"Replace {self.processor.name}")) + else: self.list_data.append((self.replace, None, "Replace")) - else: - self.list_data.append((self.replace, None, "Replace")) - if self.processor.type == "MIDI Tool" or self.processor.type == "Audio Effect": - self.list_data.append((self.processor_remove, None, "Remove")) + if self.processor.type == "MIDI Tool" or self.processor.type == "Audio Effect": + self.list_data.append((self.processor_remove, None, "Remove")) if len(self.processor.get_bank_list()) > 1 or len(self.processor.preset_list) > 0 and self.processor.preset_list[0][0] != '': self.list_data.append((self.preset_list, None, "Presets")) + if self.processor.type == "MIDI Synth": + self.list_data.append((self.randomize, None, "Randomize parameters")) + if self.last_random: + self.list_data.append((self.undo_randomize, None, "Undo Randomize")) + self.list_data.append((self.midi_clean, None, "Clean MIDI-learn")) + self.list_data.append((self.control_view, None, "Control View")) + # Processor info + self.list_data.append((self.show_details, None, "Info")) + + self.list_data.append((None, None, "> Add to chain")) + if self.processor.type in ("MIDI Synth", "MIDI Tool"): + self.list_data.append((self.add_midi_processor, None, "Insert MIDI Processor")) + if self.processor.type in ("MIDI Synth", "Audio Effect", "Audio Generator"): + self.list_data.append((self.add_audio_processor, None, "Insert Audio Processor")) super().fill_list() def build_view(self): - if self.chain is not None and self.processor is not None: - super().build_view() - if self.index >= len(self.list_data): - self.index = len(self.list_data) - 1 - return True - else: - return False + if self.processor != self.zyngui.get_current_processor(): + self.processor = self.zyngui.get_current_processor() + self.last_random = {} + super().build_view() + if self.index >= len(self.list_data): + self.index = len(self.list_data) - 1 + return True def select_action(self, i, t='S'): self.index = i @@ -92,27 +108,50 @@ def select_action(self, i, t='S'): else: self.list_data[i][0](self.list_data[i][1]) - def setup(self, chain_id, processor): - try: - self.chain = self.zyngui.chain_manager.get_chain(chain_id) - self.chain_id = chain_id - self.processor = processor - except Exception as e: - logging.error(e) - def show_details(self): self.zyngui.screens["engine"].show_details(self.processor.eng_code) + def add_processor(self, proc_type=None): + if proc_type is None: + proc_type = self.processor.type + try: + chain_idx, row, column = self.zyngui.screens["chain_manager"].selected_node + node = self.zyngui.screens["chain_manager"].nodes[chain_idx][row][column] + proc = node["proc"] + if proc.type == "MIDI Synth": + if proc_type == "Audio Effect": + slot = -1 + else: + slot = None + else: + slot = self.zyngui.screens["chain_manager"].nodes[chain_idx][row][column]["slot"] + except: + slot = None + self.zyngui.modify_chain({ + "chain_id": self.zyngui.chain_manager.active_chain.chain_id, + "type": proc_type, + "midi_thru": self.processor.midi_chan is not None, + "audio_thru": proc_type == "Audio Effect", + "slot": slot + }) + self.processor = self.zyngui.get_current_processor() + + def add_midi_processor(self): + self.add_processor("MIDI Tool") + + def add_audio_processor(self): + self.add_processor("Audio Effect") + def processor_remove(self): self.zyngui.show_confirm(f"Do you want to remove {self.processor.engine.name} from chain?", self.do_remove) def do_remove(self, unused=None): + self.zyngui.close_screen() self.zyngui.chain_manager.remove_processor( - self.chain_id, self.processor) - self.chain = None - self.chain_id = None + self.zyngui.chain_manager.active_chain.chain_id, self.processor) + zynautoconnect.request_audio_connect(True) + zynautoconnect.request_midi_connect(True) self.processor = None - self.zyngui.close_screen() def preset_list(self): self.zyngui.cuia_bank_preset(self.processor) @@ -120,46 +159,44 @@ def preset_list(self): def midi_clean(self): if self.processor: self.zyngui.show_confirm( - f"Do you want to clean MIDI-learn for ALL controls in {self.processor.name} on MIDI channel {self.processor.midi_chan + 1}?", - self.zyngui.chain_manager.clean_midi_learn, self.processor) + f"Do you want to clean MIDI-learn for ALL controls in {self.processor.name}?", self.zyngui.chain_manager.clean_midi_learn, self.processor) - # FX-Chain management - - def can_move_upchain(self): - slot = self.chain.get_slot(self.processor) - if slot is None: - return False - if slot == 0: - slots = self.chain.get_slots_by_type(self.processor.type) - if self.processor.type == "Audio Effect" and slot >= self.chain.fader_pos: - return True - return len(slots[0]) > 1 - return slot is not None and slot > 0 - - def move_upchain(self): - self.zyngui.chain_manager.nudge_processor( - self.chain_id, self.processor, True) - self.zyngui.close_screen() + def control_view(self): + self.zyngui.chain_control(hmode=self.zyngui.SCREEN_HMODE_REPLACE) - def can_move_downchain(self): - slot = self.chain.get_slot(self.processor) - if slot is None: - return False - slots = self.chain.get_slots_by_type(self.processor.type) - if slot >= len(slots) - 1: - if self.processor.type == "Audio Effect" and slot < self.chain.fader_pos: - return True - return len(slots[0]) > 1 - return slot is not None and slot + 1 < self.chain.get_slot_count(self.processor.type) - - def move_downchain(self): - self.zyngui.chain_manager.nudge_processor( - self.chain_id, self.processor, False) - self.zyngui.close_screen() + # FX-Chain management def replace(self): - self.zyngui.modify_chain( - {"chain_id": self.chain_id, "processor": self.processor, "type": self.processor.type}) + self.zyngui.modify_chain({ + "chain_id": self.zyngui.chain_manager.active_chain.chain_id, + "processor": self.processor, + "type": self.processor.type + }) + + def start_move(self, proc=None): + self.zyngui.screens.get('chain_manager').start_moving_processor() + self.zyngui.show_screen('chain_manager') + + def randomize(self): + refresh = not self.last_random + for zctrl in self.processor.controllers_dict.values(): + if zctrl.is_integer: + value = random.randint(zctrl.value_min, zctrl.value_max) + else: + value = random.random() * (zctrl.value_max - zctrl.value_min) + self.last_random[zctrl.symbol] = zctrl.value + zctrl.set_value(value) + if refresh: + self.fill_list() + + def undo_randomize(self): + for zctrl in self.processor.controllers_dict.values(): + try: + value = self.last_random[zctrl.symbol] + self.last_random[zctrl.symbol] = zctrl.value + zctrl.set_value(value) + except: + pass # Select Path diff --git a/zyngui/zynthian_gui_selector.py b/zyngui/zynthian_gui_selector.py index 296ddbd22..c34638099 100644 --- a/zyngui/zynthian_gui_selector.py +++ b/zyngui/zynthian_gui_selector.py @@ -55,6 +55,7 @@ def __init__(self, selcap='Select', wide=False, loading_anim=True, tiny_ctrls=Tr if "ctrl_width" not in self.layout: self.layout['ctrl_width'] = 0.25 + self.ctrl_order = zynthian_gui_config.layout['ctrl_order'] self.index = 0 self.scroll_y = 0 self.list_data = [] @@ -421,10 +422,16 @@ def zynpot_cb(self, i, dval): return False def arrow_up(self): - self.select(self.index - 1) + if self.param_editor_zctrl: + self.zynpot_cb(self.ctrl_order[3], 1) + else: + self.select(self.index - 1) def arrow_down(self): - self.select(self.index + 1) + if self.param_editor_zctrl: + self.zynpot_cb(self.ctrl_order[3], -1) + else: + self.select(self.index + 1) # -------------------------------------------------------------------------- # Keyboard & Mouse/Touch Callbacks diff --git a/zyngui/zynthian_gui_selector_grid.py b/zyngui/zynthian_gui_selector_grid.py new file mode 100644 index 000000000..03ef873bf --- /dev/null +++ b/zyngui/zynthian_gui_selector_grid.py @@ -0,0 +1,354 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian GUI +# +# Zynthian GUI Selector Grid Class +# +# Copyright (C) 2025 Brian Walton +# +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import logging +import tkinter +from PIL import Image, ImageTk + +from zyngui import zynthian_gui_config +from zyngui.zynthian_gui_base import zynthian_gui_base + + +class zynthian_gui_selector_grid(zynthian_gui_base): + """ + Selector presented as a grid of buttons. + """ + def __init__(self): + """ + Initialize the Grid View. + + Sets up the canvas, data structures for nodes and grid navigation, + and initializes mouse drag state variables. + """ + super().__init__('Selector Grid') + + self.columns = 3 + + # Initial values, recalculated by update_layout + self.BLOCK_WIDTH = 120 # Width of each processor block in pixels + self.BLOCK_HEIGHT = 40 # Height of each processor block in pixels + self.SPACING = 10 # Horizontal spacing between processor blocks in pixels + self.font = (zynthian_gui_config.font_family, int(0.065 * self.BLOCK_WIDTH)) + self.icon_size = (8, 8) + + self.config = [] # List of dictionaries, each describing a button + self.selected_node = 0 # Selected node id + + # Canvas for drawing the graph + self.canvas = tkinter.Canvas(self.main_frame, bg=zynthian_gui_config.color_panel_bg, highlightthickness=0) + self.canvas.pack(fill=tkinter.BOTH, expand=True) + # Bind Mouse Events + self.canvas.bind("", self.on_press) + self.canvas.bind("", self.on_drag) + self.canvas.bind("", self.on_release) + self.canvas.bind("", self.on_wheel) + self.canvas.bind("", self.on_wheel) + + # Mouse Drag State + self.drag_start_x = 0 + self.drag_start_y = 0 + self.is_dragging = False + self.drag_threshold = 5 # pixels to detect drag vs click + self.press_event = None + + def update_layout(self): + super().update_layout() + # Formula 2 * (x // y) ensures even values which helps with spacing and dividers + self.SPACING = 2 * (self.width // (self.columns * 20)) + self.BLOCK_WIDTH = 2 * ((self.width - self.SPACING) // (self.columns * 2)) - self.SPACING + #self.BLOCK_HEIGHT = 2 * (self.BLOCK_WIDTH // 5) + self.BLOCK_HEIGHT = 2 * ((self.height - self.SPACING) // (self.columns * 2)) - self.SPACING + self.font = (zynthian_gui_config.font_family, int(0.06 * self.BLOCK_WIDTH)) + icon_h = self.BLOCK_HEIGHT - int(0.5 * self.SPACING) + self.icon_size = (icon_h, icon_h) + self._draw_nodes() + + def build_view(self): + self._draw_nodes() + return True + + def setup(self, config, cols=None): + """ + Configure the buttons + + :param config: List of dictionaries, each describing a button + """ + self.config = config + if cols: + self.columns = cols + + def get_icon(self, icon_fname): + if not icon_fname: + icon_fname = self.default_icon + if icon_fname not in self.icons: + try: + img = Image.open(f"{self.ui_dir}/icons/{icon_fname}") + icon = ImageTk.PhotoImage(img.resize(self.icon_size)) + self.icons[icon_fname] = icon + return icon + except Exception as e: + logging.error(f"Can't load info icon {icon_fname} => {e}") + return None + else: + return self.icons[icon_fname] + + def _draw_nodes(self): + if self.width == 1: + return # Not yet resized + self.canvas.delete("all") + self.icons = {} + x = self.SPACING + y = self.SPACING + for idx, node in enumerate(self.config): + self.canvas.create_rectangle(x, y, x + self.BLOCK_WIDTH, y + self.BLOCK_HEIGHT, + fill="#666666", + outline="#666666", + tags=("node", f"node_{idx}")) + if "icon" in node: + img = self.get_icon(node["icon"]) + if img: + self.canvas.create_image(x, y + self.BLOCK_HEIGHT // 2, image=img, anchor="w") + self.canvas.create_text( + x + 2 * self.BLOCK_WIDTH // 3, y + self.BLOCK_HEIGHT // 2, + text=node["title"], + fill="white", + font=self.font, + width=self.BLOCK_WIDTH // 2, + justify=tkinter.CENTER + ) + x += self.BLOCK_WIDTH + self.SPACING + if x + self.BLOCK_WIDTH + self.SPACING > self.width: + x = self.SPACING + y += self.BLOCK_HEIGHT + self.SPACING + + # Configure scroll region + bbox = self.canvas.bbox("all") + if bbox: + self.canvas.configure(scrollregion=(bbox[0] - self.SPACING, bbox[1] - self.SPACING, bbox[2] + self.SPACING, bbox[3] + self.SPACING)) + else: + self.canvas.configure(scrollregion=(0, 0, 100, 100)) + + self._draw_selection() + + def _draw_selection(self): + """ + Draw selection cursor. + """ + self.canvas.itemconfig("node", outline="") + node_tag = f"node_{self.selected_node}" + self.canvas.itemconfig(node_tag, outline="yellow", width=2) + + #Scroll the canvas to ensure the selected node is visible. + # Get node's coords + x0, y0, x1, y1 = self.canvas.bbox(node_tag) + # Get view coords + vw = self.width + vh = self.height + vx0 = self.canvas.canvasx(0) + vy0 = self.canvas.canvasy(0) + vx1 = self.canvas.canvasx(vw) + vy1 = self.canvas.canvasy(vh) + b0, b1, b2, b3 = self.canvas.bbox("all") + w = b2 - b0 + h = b3 - b1 + # Scroll horizontally + if x0 < vx0: + self.canvas.xview_moveto((x0 - b0) / w) + elif x1 > vx1: + self.canvas.xview_moveto((x1 - vw) / w) + # Scroll vertically + if y0 < vy0: + self.canvas.yview_moveto((y0 - b1) / h) + elif y1 > vy1: + self.canvas.yview_moveto((y1 - vh) / h) + + def arrow_left(self): + """ + Handle arrow left action. + """ + + idx = self.selected_node - 1 + if idx < 0: + return + self.selected_node = idx + self._draw_selection() + + def arrow_right(self): + """ + Handle arrow right action. + """ + + idx = self.selected_node + 1 + if idx >= len(self.config): + return + self.selected_node = idx + self._draw_selection() + + def arrow_up(self): + """ + Handle arrow up action + """ + idx = self.selected_node - self.columns + if idx < 0: + return + self.selected_node = idx + self._draw_selection() + + def arrow_down(self): + """ + Handle arrow down action + """ + idx = self.selected_node + self.columns + if idx >= len(self.config): + return + self.selected_node = idx + self._draw_selection() + + def select_offset(self, dval): + idx = self.selected_node + dval + if idx < 0 or idx >= len(self.config): + return + self.selected_node = idx + self._draw_selection() + + def on_wheel(self, event): + """ + Handle mouse wheel events to navigate the graph. + + Args: + event: The mouse wheel event. + """ + if event.num == 5 or event.delta == -120: + self.select_offset(1) + elif event.num == 4 or event.delta == 120: + self.select_offset(-1) + + def zynpot_cb(self, i, dval): + if super().zynpot_cb(i, dval): + return True + if i == 3: + self.select_offset(dval) + return True + elif i == 2: + if dval > 0: + self.arrow_down() + elif dval < 0: + self.arrow_up() + + def on_press(self, event): + """ + Handle mouse button press. Initializes drag state. + Args: + event: Mouse event + """ + # Record start position for drag + self.drag_start_x = event.x + self.drag_start_y = event.y + self.start_xview = self.canvas.xview()[0] + self.start_yview = self.canvas.yview()[0] + self.is_dragging = False + self.press_event = event + + def on_drag(self, event): + """ + Handle mouse drag event. Scrolls the canvas. + Args: + event: Mouse event + """ + # Calculate pixel delta + dx = self.drag_start_x - event.x + dy = self.drag_start_y - event.y + + # Check threshold + if not self.is_dragging: + if abs(dx) > self.drag_threshold or abs(dy) > self.drag_threshold: + self.is_dragging = True + + if self.is_dragging: + # Scroll Canvas manually using moveto + # We need the total scrollable size to convert pixels to fraction + try: + # scrollregion is "x1 y1 x2 y2" string or tuple + sr = self.canvas.cget("scrollregion") + if isinstance(sr, str): + sr = [float(x) for x in sr.split()] + sr_w = sr[2] - sr[0] + sr_h = sr[3] - sr[1] + can_w = self.canvas.winfo_width() + can_h = self.canvas.winfo_height() + # Horizontal Move + if sr_w > can_w: + d_fract_x = dx / float(sr_w) + self.canvas.xview_moveto(self.start_xview + d_fract_x) + # Vertical Move + if sr_h > can_h: + d_fract_y = dy / float(sr_h) + self.canvas.yview_moveto(self.start_yview + d_fract_y) + except Exception as e: + logging.warning(f"Drag scroll error: {e}") + pass + + def on_release(self, event): + """ + Handle mouse button release. + Args: + event: Mouse event + """ + # Use canvasx/y to account for scrolling + x, y = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y) + # Find closest node or clicked node + items = self.canvas.find_overlapping(x, y, x, y) + try: + tags = self.canvas.gettags(items[0]) + self.selected_node = int(tags[1].split("_")[1]) + except: + return + self._draw_selection() + press_type = "S" + if self.press_event: + if event.time > self.press_event.time + 400: + press_type = "B" + self.press_event = None + self.switch_select(press_type) + + def switch_select(self, press_type="S"): + config = self.config[self.selected_node] + if press_type == "B": + action_fn = config.get("bold_action") + if action_fn: + action_params = config.get("action_bold_params") + if action_params: + action_fn(*action_params) + else: + action_fn() + return + action_fn = config.get("action") + if action_fn: + action_params = config.get("action_params") + if action_params: + action_fn(*action_params) + else: + action_fn() diff --git a/zyngui/zynthian_gui_snapshot.py b/zyngui/zynthian_gui_snapshot.py index 2bb215154..b76419489 100644 --- a/zyngui/zynthian_gui_snapshot.py +++ b/zyngui/zynthian_gui_snapshot.py @@ -350,22 +350,19 @@ def load_snapshot(self, fpath): state = self.sm.load_snapshot(fpath) if state is None: self.zyngui.clean_all() - elif "zyngui" in state: - if self.load_zyngui(state["zyngui"]): - return - self.zyngui.show_screen('audio_mixer', self.zyngui.SCREEN_HMODE_RESET) + self.zyngui.show_screen('root', self.zyngui.SCREEN_HMODE_RESET) def load_snapshot_chains(self, fpath, merge=False): if self.is_not_empty_snapshot() and fpath != self.sm.last_state_snapshot_fpath: self.sm.save_last_state_snapshot() self.sm.load_snapshot(fpath, load_sequences=False, merge=merge) - self.zyngui.show_screen('audio_mixer', self.zyngui.SCREEN_HMODE_RESET) + self.zyngui.show_screen('root', self.zyngui.SCREEN_HMODE_RESET) def load_snapshot_sequences(self, fpath): if self.is_not_empty_snapshot() and fpath != self.sm.last_state_snapshot_fpath: self.sm.save_last_state_snapshot() self.sm.load_snapshot(fpath, load_chains=False) - self.zyngui.show_screen('zynpad', hmode=self.zyngui.SCREEN_HMODE_RESET) + self.zyngui.show_screen('launcher', hmode=self.zyngui.SCREEN_HMODE_RESET) def restore_backup_cb(self, fname, fpath): logging.debug("Restoring snapshot backup '{}'".format(fname)) @@ -470,7 +467,7 @@ def save_snapshot_by_name(self, name): def save_snapshot(self, path): self.sm.backup_snapshot(path) self.sm.save_snapshot(path) - self.zyngui.show_screen('audio_mixer', self.zyngui.SCREEN_HMODE_RESET) + self.zyngui.show_screen('root', self.zyngui.SCREEN_HMODE_RESET) def delete_confirmed(self, fpath): logging.info("DELETE SNAPSHOT: {}".format(fpath)) @@ -496,18 +493,4 @@ def set_select_path(self): title = f"Snapshots: {self.zyngui.state_manager.snapshot_bank}" self.select_path.set(title) - def load_zyngui(self, state): - """Load zyngui configuration from snapshot state - - state : zyngui state dictionary - Returns : True if screen navigation performed - TODO: Parse zyngui configuration from snapshot - """ - - try: - self.zyngui.show_screen(state["current_screen"], self.zyngui.SCREEN_HMODE_RESET) - return True - except: - return False - # ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_gui_tempo.py b/zyngui/zynthian_gui_tempo.py deleted file mode 100644 index 6fe9b267e..000000000 --- a/zyngui/zynthian_gui_tempo.py +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# ****************************************************************************** -# ZYNTHIAN PROJECT: Zynthian GUI -# -# Zynthian GUI Tempo class -# -# Copyright (C) 2015-2024 Fernando Moyano -# -# ****************************************************************************** -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License as -# published by the Free Software Foundation; either version 2 of -# the License, or any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# For a full copy of the GNU General Public License see the LICENSE.txt file. -# -# ****************************************************************************** - -import tkinter -import logging -from time import monotonic -from collections import deque - -# Zynthian specific modules - -from zyncoder.zyncore import lib_zyncore -from zynlibs.zynaudioplayer import * -from zyngine import zynthian_controller -from zyngui import zynthian_gui_config -from zyngui.zynthian_gui_base import zynthian_gui_base -from zyngui.zynthian_gui_selector import zynthian_gui_controller -from zyngine.zynthian_signal_manager import zynsigman - -# ------------------------------------------------------------------------------ -# Zynthian Tempo GUI Class -# ------------------------------------------------------------------------------ - - -class zynthian_gui_tempo(zynthian_gui_base): - - NUM_TAPS = 4 - - def __init__(self): - self.buttonbar_config = [ - ("toggle_audio_play", "Play\nAudio"), - ("toggle_audio_record", "Record\nAudio"), - ("toggle_midi_play", "Play\nMIDI"), - ("toggle_midi_record", "Record\nMIDI") - ] - - super().__init__() - - self.state_manager = self.zyngui.state_manager - self.libseq = self.state_manager.zynseq.libseq - - self.tap_buf = None - self.last_tap_ts = 0 - - # Create zctrl objects - self.zgui_ctrls = [] - - self.bpm_zctrl = zynthian_controller(self, 'bpm', - {'name': 'BPM', - 'value_min': 10, - 'value_max': 420, - 'nudge_factor': 1.0, - 'is_integer': False, - 'value': self.libseq.getTempo()}) - self.bpm_zgui_ctrl = zynthian_gui_controller(0, self.main_frame, self.bpm_zctrl) - self.zgui_ctrls.append(self.bpm_zgui_ctrl) - - self.clk_source_zctrl = zynthian_controller(self, 'clock_source', - {'name': 'Clock Source', - 'labels': ['Internal', 'Internal Send', 'MIDI', 'Sync Beat', - 'Sync Beat/2', 'Sync Beat/3', 'Sync Beat/4'], - 'ticks': [0, 1, 2, 3, 4, 5, 6], - 'value': self.get_clk_source_value()}) - self.clk_source_zgui_ctrl = zynthian_gui_controller(1, self.main_frame, self.clk_source_zctrl) - self.zgui_ctrls.append(self.clk_source_zgui_ctrl) - - self.mtr_enable_zctrl = zynthian_controller(self, 'metronome_enable', - {'name': 'Metronome On/Off', - 'labels': ['Off', 'On'], - 'ticks': [0, 1], - 'is_toggle': True, - 'value': self.libseq.isMetronomeEnabled()}) - self.mtr_enable_zgui_ctrl = zynthian_gui_controller(2, self.main_frame, self.mtr_enable_zctrl) - self.zgui_ctrls.append(self.mtr_enable_zgui_ctrl) - - self.mtr_volume_zctrl = zynthian_controller(self, 'metronome_volume', - {'name': 'Metronome Volume', - 'value_min': 0, - 'value_max': 100, - 'value': int(100 * self.libseq.getMetronomeVolume())}) - self.mtr_volume_zgui_ctrl = zynthian_gui_controller(3, self.main_frame, self.mtr_volume_zctrl) - self.zgui_ctrls.append(self.mtr_volume_zgui_ctrl) - - # Create graphic elements - self.info_canvas = tkinter.Canvas(self.main_frame, height=1, width=1, - bg=zynthian_gui_config.color_panel_bg, bd=0, highlightthickness=0) - - self.main_frame.rowconfigure(2, weight=1) - if zynthian_gui_config.layout['columns'] == 3: - self.info_canvas.grid(row=0, column=1, rowspan=2, padx=(2, 2), sticky='news') - self.main_frame.columnconfigure(1, weight=1) - else: - self.info_canvas.grid(row=0, column=0, rowspan=4, padx=(0, 2), sticky='news') - self.main_frame.columnconfigure(0, weight=1) - - self.bpm_text = self.info_canvas.create_text( - 0, - 0, - anchor=tkinter.N, - width=0, - text="", - font=(zynthian_gui_config.font_family, 10), - fill=zynthian_gui_config.color_panel_tx) - - self.replot = True - - def set_zctrls(self): - self.bpm_zgui_ctrl.refresh_plot_value = True - layout = zynthian_gui_config.layout - for zgui_ctrl in self.zgui_ctrls: - i = zgui_ctrl.index - zgui_ctrl.setup_zynpot() - zgui_ctrl.erase_midi_bind() - zgui_ctrl.configure(height=self.height // layout['rows'], width=self.width // 4) - zgui_ctrl.grid(row=layout['ctrl_pos'][i][0], column=layout['ctrl_pos'][i][1]) - - def update_text(self): - self.info_canvas.itemconfigure( - self.bpm_text, text="{:.1f} BPM".format(self.bpm_zctrl.get_value())) - - def update_layout(self): - super().update_layout() - fs = self.width // 20 - if zynthian_gui_config.layout['columns'] == 3: - self.info_canvas.coords(self.bpm_text, int(0.25*self.width), int(0.375*self.height)) - else: - self.info_canvas.coords(self.bpm_text, int(0.375*self.width), int(0.375*self.height)) - self.info_canvas.itemconfigure(self.bpm_text, width=9*fs, font=(zynthian_gui_config.font_family, fs)) - - def plot_zctrls(self): - self.refresh_bpm_value() - if self.replot: - for zgui_ctrl in self.zgui_ctrls: - if zgui_ctrl.zctrl.is_dirty: - zgui_ctrl.calculate_plot_values() - zgui_ctrl.plot_value() - zgui_ctrl.zctrl.is_dirty = False - self.update_text() - self.replot = False - - def build_view(self): - self.set_zctrls() - self.last_tap_ts = 0 - if zynthian_gui_config.enable_touch_navigation: - zynsigman.register(zynsigman.S_AUDIO_PLAYER, - self.zyngui.state_manager.SS_AUDIO_PLAYER_STATE, self.cb_status_audio_player) - zynsigman.register(zynsigman.S_AUDIO_RECORDER, - self.zyngui.state_manager.SS_AUDIO_RECORDER_STATE, self.cb_status_audio_recorder) - zynsigman.register(zynsigman.S_STATE_MAN, - self.zyngui.state_manager.SS_MIDI_PLAYER_STATE, self.cb_status_midi_player) - zynsigman.register(zynsigman.S_STATE_MAN, - self.zyngui.state_manager.SS_MIDI_RECORDER_STATE, self.cb_status_midi_recorder) - self.cb_status_audio_player() - self.cb_status_audio_recorder() - self.cb_status_midi_player() - self.cb_status_midi_recorder() - return True - - def hide(self): - if self.shown: - if zynthian_gui_config.enable_touch_navigation: - zynsigman.unregister(zynsigman.S_AUDIO_PLAYER, - self.zyngui.state_manager.SS_AUDIO_PLAYER_STATE, self.cb_status_audio_player) - zynsigman.unregister(zynsigman.S_AUDIO_RECORDER, - self.zyngui.state_manager.SS_AUDIO_RECORDER_STATE, self.cb_status_audio_recorder) - zynsigman.unregister(zynsigman.S_STATE_MAN, - self.zyngui.state_manager.SS_MIDI_PLAYER_STATE, self.cb_status_midi_player) - zynsigman.unregister(zynsigman.S_STATE_MAN, - self.zyngui.state_manager.SS_MIDI_RECORDER_STATE, self.cb_status_midi_recorder) - return super().hide() - - def zynpot_cb(self, i, dval): - if i < 4: - self.zgui_ctrls[i].zynpot_cb(dval) - return True - else: - return False - - def zynpot_abs(self, i, val): - if i < 4: - self.zgui_ctrls[i].zynpot_abs(val) - return True - else: - return False - - def send_controller_value(self, zctrl): - if self.shown: - if zctrl == self.bpm_zctrl: - self.libseq.setTempo(zctrl.value) - zynaudioplayer.set_tempo(zctrl.value) - logging.debug("SETTING TEMPO BPM: {}".format(zctrl.value)) - self.replot = True - - if zctrl == self.clk_source_zctrl: - self.set_clk_source_value(zctrl.value) - self.replot = True - - elif zctrl == self.mtr_enable_zctrl: - self.libseq.enableMetronome(zctrl.value) - logging.debug("SETTING METRONOME ENABLE: {}".format(zctrl.value)) - self.replot = True - - elif zctrl == self.mtr_volume_zctrl: - self.libseq.setMetronomeVolume(zctrl.value/100.0) - logging.debug("SETTING METRONOME VOLUME: {}".format(zctrl.value)) - self.replot = True - - def get_clk_source_value(self): - cs = self.state_manager.get_transport_clock_source() - if cs == 3: - cs += self.state_manager.get_transport_analog_clock_divisor() - 1 - if cs > 6: - cs = 3 - return cs - - def set_clk_source_value(self, val): - if val >= 3: - self.state_manager.set_transport_analog_clock_divisor(val - 2, save_config=True) - logging.debug("SETTING ANALOG CLOCK DIVISOR: {}".format(val - 2)) - val = 3 - self.state_manager.set_transport_clock_source(val, save_config=True) - logging.debug("SETTING CLOCK SOURCE: {}".format(val)) - - def tap(self): - now = monotonic() - tap_dur = now - self.last_tap_ts - if self.last_tap_ts == 0 or tap_dur < 0.14285 or tap_dur > 2: - self.last_tap_ts = now - self.tap_buf = deque(maxlen=self.NUM_TAPS) - else: - self.last_tap_ts = now - if self.clk_source_zctrl.value >= 3: - lib_zyncore.zynstep_send_clock() - logging.debug("TAP SYNCING (BEAT + TEMPO)!") - else: - self.tap_buf.append(tap_dur) - logging.debug("TAP TEMPO BUFFER: {}".format(self.tap_buf)) - bpm = 60 * len(self.tap_buf) / sum(self.tap_buf) - self.libseq.setTempo(bpm) - logging.debug("SETTING TAP TEMPO BPM: {}".format(bpm)) - - def refresh_bpm_value(self): - self.bpm_zctrl.set_value(self.libseq.getTempo(), send=False) - if self.bpm_zctrl.is_dirty: - self.replot = True - - def switch_select(self, t='S'): - self.zyngui.close_screen() - - def set_select_path(self): - self.select_path.set("Tempo Settings") - - def cb_status_audio_player(self, handle=None, state=None): - self.set_button_status(0, self.zyngui.state_manager.status_audio_player) - - def cb_status_audio_recorder(self, chan=None, state=None): - self.set_button_status(1, self.zyngui.state_manager.audio_recorder.status) - - def cb_status_midi_player(self, handle=None, state=None): - self.set_button_status(2, self.zyngui.state_manager.status_midi_player) - - def cb_status_midi_recorder(self, chan=None, state=None): - self.set_button_status(3, self.zyngui.state_manager.status_midi_recorder) - - def cb_button_release(self, event): - match event.widget.cuia: - case "toggle_audio_play": - self.zyngui.cuia_toggle_audio_play() - case "toggle_audio_record": - self.zyngui.cuia_toggle_audio_record() - case "toggle_midi_play": - self.zyngui.cuia_toggle_midi_play() - case "toggle_midi_record": - self.zyngui.cuia_toggle_midi_record() - -# ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_gui_zs3.py b/zyngui/zynthian_gui_zs3.py index 5fc37abb3..e4ad5e907 100644 --- a/zyngui/zynthian_gui_zs3.py +++ b/zyngui/zynthian_gui_zs3.py @@ -159,7 +159,7 @@ def show_menu(self): def toggle_menu(self): if self.shown: self.show_menu() - elif self.zyngui.current_screen == "zs3_options": + elif self.zyngui.get_current_screen() == "zs3_options": self.zyngui.close_screen() def enable_midi_learn(self): diff --git a/zyngui/zynthian_gui_zs3_options.py b/zyngui/zynthian_gui_zs3_options.py index 8764599f6..7d28150b5 100644 --- a/zyngui/zynthian_gui_zs3_options.py +++ b/zyngui/zynthian_gui_zs3_options.py @@ -108,52 +108,62 @@ def zs3_restoring_submenu(self): self.zyngui.show_screen('option') def zs3_restoring_options_cb(self): + """ Create a tree of chains/processors defined within zs3 to toggle restore flag""" try: state = self.zyngui.state_manager.zs3[self.zs3_id] except: logging.error(f"Bad ZS3 id ({self.zs3_id}).") return - options = {} + options = {"Toggle All Mixer": ""} + mixer_list = [] - # Restoring Audio Mixer - mixer_state = state["mixer"] - try: - restore_flag = mixer_state["restore"] - except: - restore_flag = True - if restore_flag: - options["\u2612 Mixer"] = "mixer" - else: - options["\u2610 Mixer"] = "mixer" - - # Restoring chains - options["Chains"] = None - if "chains" in state: - for chain_id, chain_state in state["chains"].items(): - chain_id = int(chain_id) - chain = self.zyngui.chain_manager.get_chain(chain_id) - if chain is None: - continue + for idx, chain_id in enumerate(self.zyngui.chain_manager.chains): + chain = self.zyngui.chain_manager.get_chain(chain_id) + if chain is None: + continue + if chain_id: + label = f"{idx + 1} {chain.get_name()}" + else: label = chain.get_name() - while f"\u2612 {label}" in options or f"\u2610 {label}" in options: - # Make each option title unique so that they are not omitted from the options menu - label += " " + if "chains" in state and chain_id in state["chains"]: try: - restore_flag = chain_state["restore"] + restore = state["chains"][chain_id]["restore"] except: - restore_flag = True - if restore_flag: - options[f"\u2612 {label}"] = chain_id + restore = True + if restore: + options[f"\u2612 {label}"] = f"chains_{chain_id}" else: - options[f"\u2610 {label}"] = chain_id - + options[f"\u2610 {label}"] = f"chains_{chain_id}" + else: + options[label] = None + for proc in chain.get_processors(): + if proc.id in state["processors"]: + try: + restore = state["processors"][proc.id]["restore"] + except: + restore = True + if proc.eng_code in ("MI", "MR"): + label = f"{proc.name}" + mixer_list.append(str(proc.id)) + else: + label = f"{proc.name} ({proc.id})" + if restore: + options[f"\u2612 ⤷{label}"] = f"processors_{proc.id}" + else: + options[f"\u2610 ⤷{label}"] = f"processors_{proc.id}" + options["Toggle All Mixer"] = ",".join(mixer_list) return options - def zs3_restoring_options_select_cb(self, label, id, ct): + def zs3_restoring_options_select_cb(self, label, param, ct): + if label == "Toggle All Mixer": + ids = param.split(",") + for id in ids: + self.zyngui.state_manager.toggle_zs3_restore_flag(self.zs3_id, "processors", id) + return + type, id = param.split("_") if ct == "S": - self.zyngui.state_manager.toggle_zs3_chain_restore_flag( - self.zs3_id, id) + self.zyngui.state_manager.toggle_zs3_restore_flag(self.zs3_id, type, id) elif ct == "B": try: state = self.zyngui.state_manager.zs3[self.zs3_id] @@ -161,7 +171,7 @@ def zs3_restoring_options_select_cb(self, label, id, ct): logging.error("Bad ZS3 ID ({}).".format(self.zs3_id)) return # Invert selection (toggle all elements in list) - for chain_id in list(state["chains"]) + ["mixer"]: + for chain_id in list(state["chains"]) + list(state["processors"]): self.zyngui.state_manager.toggle_zs3_chain_restore_flag( self.zs3_id, chain_id) @@ -176,17 +186,7 @@ def zs3_rename_cb(self, title): def zs3_update(self): logging.info("Updating ZS3 '{}'".format(self.zs3_id)) - restore_chains = [] - state = self.zyngui.state_manager.zs3[self.zs3_id] - if "chains" in state: - for chain_id, chain_state in state["chains"].items(): - if "restore" in chain_state and not chain_state["restore"]: - restore_chains.append(chain_id) self.zyngui.state_manager.save_zs3(self.zs3_id) - for chain_id in restore_chains: - self.zyngui.state_manager.toggle_zs3_chain_restore_flag(self.zs3_id, chain_id) - if "restore" in state["mixer"] and state["mixer"]["restore"] == False: - self.zyngui.state_manager.zs3[self.zs3_id]["mixer"]["restore"] = False self.zyngui.close_screen() def zs3_delete(self): diff --git a/zyngui/zynthian_gui_zynpad.py b/zyngui/zynthian_gui_zynpad.py deleted file mode 100644 index 955b18c47..000000000 --- a/zyngui/zynthian_gui_zynpad.py +++ /dev/null @@ -1,857 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# ****************************************************************************** -# ZYNTHIAN PROJECT: Zynthian GUI -# -# Zynthian GUI Step-Sequencer Pad Trigger Class -# -# Copyright (C) 2015-2024 Fernando Moyano -# Brian Walton -# -# ****************************************************************************** -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License as -# published by the Free Software Foundation; either version 2 of -# the License, or any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# For a full copy of the GNU General Public License see the LICENSE.txt file. -# -# ****************************************************************************** - -import tkinter -import logging -from PIL import Image, ImageTk -from threading import Timer - -# Zynthian specific modules -import zynautoconnect -from zynlibs.zynseq import zynseq -from zyncoder.zyncore import lib_zyncore -from zyngine.zynthian_signal_manager import zynsigman - -from . import zynthian_gui_base -from zyngui import zynthian_gui_config -from zyngui.zynthian_gui_patterneditor import EDIT_MODE_NONE - - -SELECT_BORDER = zynthian_gui_config.color_on -INPUT_CHANNEL_LABELS = ['OFF', '1', '2', '3', '4', '5', '6', - '7', '8', '9', '10', '11', '12', '13', '14', '15', '16'] -NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] - -# ------------------------------------------------------------------------------ -# Zynthian Step-Sequencer Sequence / Pad Trigger GUI Class -# ------------------------------------------------------------------------------ - - -class zynthian_gui_zynpad(zynthian_gui_base.zynthian_gui_base): - - # Function to initialise class - def __init__(self): - logging.getLogger('PIL').setLevel(logging.WARNING) - - super().__init__() - - # Zynthian Core objects - self.state_manager = self.zyngui.state_manager - self.zynseq = self.state_manager.zynseq - self.chain_manager = self.state_manager.chain_manager - - self.ctrl_order = zynthian_gui_config.layout['ctrl_order'] - self.selected_pad = 0 # Index of selected pad - self.redraw_pending = 2 # 0=no refresh pending, 1=update grid, 2=rebuild grid - self.redrawing = False # True to block further redraws until complete - # The last successfully selected bank - used to update stale views - self.bank = self.zynseq.bank - # Columns used during last layout - used to update stale views - self.columns = self.zynseq.col_in_bank - self.midi_learn = False - self.trigger_channel = None - self.trigger_device = None - - # Geometry vars - # Scale thickness of select border based on screen - self.select_thickness = 1 + int(self.width / 400) - self.column_width = self.width / self.zynseq.col_in_bank - self.row_height = self.height / self.zynseq.col_in_bank - - # Pad grid - self.grid_canvas = tkinter.Canvas(self.main_frame, - width=self.width, - height=self.height, - bd=0, - highlightthickness=0, - bg=zynthian_gui_config.color_bg) - self.main_frame.columnconfigure(0, weight=1) - self.main_frame.rowconfigure(0, weight=1) - self.grid_canvas.grid() - # Grid press and hold timer - self.grid_timer = Timer(1.4, self.on_grid_timer) - - self.build_grid() - - # Capture some signals ALL the time - zynsigman.register( - zynsigman.S_STATE_MAN, self.state_manager.SS_LOAD_SNAPSHOT, self.refresh_trigger_params) - zynsigman.register( - zynsigman.S_MIDI, zynsigman.SS_MIDI_NOTE_ON, self.cb_midi_note_on) - - # Function to setup encoder's behaviour - def setup_zynpots(self): - for i in range(zynthian_gui_config.num_zynpots): - lib_zyncore.setup_behaviour_zynpot(i, 0) - - # Function to show GUI - # params: Misc parameters - def build_view(self): - self.zynseq.libseq.updateSequenceInfo() - self.setup_zynpots() - self.refresh_status(True) - self.refresh_trigger_params() - if self.param_editor_zctrl == None: - self.set_title(f"Scene {self.bank}") - zynsigman.register( - zynsigman.S_STEPSEQ, self.zynseq.SS_SEQ_PLAY_STATE, self.update_play_state) - zynsigman.register(zynsigman.S_STEPSEQ, - self.zynseq.SS_SEQ_PROGRESS, self.update_progress) - return True - - # Function to hide GUI - def hide(self): - if self.shown: - zynsigman.unregister( - zynsigman.S_STEPSEQ, self.zynseq.SS_SEQ_PLAY_STATE, self.update_play_state) - zynsigman.unregister( - zynsigman.S_STEPSEQ, self.zynseq.SS_SEQ_PROGRESS, self.update_progress) - super().hide() - - # Function to set quantity of pads - def set_grid_size(self, value): - columns = value + 1 - if columns > 8: - columns = 8 - if self.zynseq.libseq.getSequencesInBank(self.bank) > columns ** 2: - self.zyngui.show_confirm( - f"Reducing the quantity of sequences in bank {self.bank} will delete sequences but patterns will still be available. Continue?", self._set_grid_size, columns) - else: - self._set_grid_size(columns) - - def _set_grid_size(self, value): - self.zynseq.update_bank_grid(value) - self.refresh_status(force=True) - - # Function to name selected sequence - def rename_sequence(self, params=None): - current_name = self.zynseq.get_sequence_name(self.bank, self.selected_pad) - self.zyngui.show_keyboard(self.do_rename_sequence, current_name, 16) - - # Function to rename selected sequence - def do_rename_sequence(self, name): - self.zynseq.set_sequence_name(self.bank, self.selected_pad, name) - self.refresh_pad(self.selected_pad) - - def update_layout(self): - super().update_layout() - self.redraw_pending = 2 - self.update_grid() - - # Function to create 64 pads - def build_grid(self): - columns = 8 - column_width = self.width / columns - row_height = self.height / columns - fs1 = int(row_height * 0.15) - fs2 = int(row_height * 0.11) - self.selection = self.grid_canvas.create_rectangle(0, 0, int(self.column_width), int( - self.row_height), fill="", outline=SELECT_BORDER, width=self.select_thickness, tags="selection") - - self.pads = [] - for pad in range(64): - pad_struct = {} - pad_x = int(pad / columns) * column_width - pad_y = pad % columns * row_height - header_h = int(0.28 * self.row_height) - pad_struct["header"] = self.grid_canvas.create_rectangle(pad_x, pad_y, pad_x + self.column_width - 2, pad_y + header_h, - fill='darkgrey', - width=0, - tags=(f"padh:{pad}", "gridcell", f"pad:{pad}", "pad")) - pad_struct["body"] = self.grid_canvas.create_rectangle(pad_x, pad_y + header_h, pad_x + self.column_width - 2, pad_y + self.row_height - 2, - fill='grey', - width=0, - tags=(f"padb:{pad}", "gridcell", f"pad:{pad}", "pad")) - posx = pad_x + int(0.02 * self.column_width) - posy = pad_y + int(0.04 * self.row_height) - pad_struct["mode"] = self.grid_canvas.create_image(posx + int(0.125 * self.column_width), posy, - anchor="nw", - tags=(f"mode:{pad}", f"pad:{pad}", "pad")) - posy = pad_y + int(0.05 * self.row_height) - pad_struct["group"] = self.grid_canvas.create_text(posx + int(3 * 0.125 * self.column_width), posy, - anchor="n", - font=( - zynthian_gui_config.font_family, fs2), - fill=zynthian_gui_config.color_panel_tx, - tags=(f"group:{pad}", f"pad:{pad}", "pad")) - pad_struct["num"] = self.grid_canvas.create_text(posx + int(5 * 0.125 * self.column_width), posy, - anchor="n", - font=( - zynthian_gui_config.font_family, fs2), - fill=zynthian_gui_config.color_panel_tx, - tags=(f"num:{pad}", f"pad:{pad}", "pad")) - pad_struct["state"] = self.grid_canvas.create_image(posx + int(7 * 0.125 * self.column_width), posy, - anchor="n", - tags=(f"state:{pad}", f"pad:{pad}", "pad")) - posx = pad_x + int(0.03 * self.column_width) - pad_struct["title"] = self.grid_canvas.create_text(posx, posy + 2 * fs1, - width=self.column_width - 0.06 * self.column_width, - anchor="nw", justify="left", - font=( - zynthian_gui_config.font_family, fs1), - fill=zynthian_gui_config.color_panel_tx, - tags=(f"title:{pad}", f"pad:{pad}", "pad")) - pad_struct["progress"] = self.grid_canvas.create_rectangle(0, 0, 0, 0, - fill='white', - width=0, - tags=(f"progress:{pad}", f"pad:{pad}", "pad")) - self.pads.append(pad_struct) - self.grid_canvas.tag_bind("pad", '', self.on_pad_press) - self.grid_canvas.tag_bind( - "pad", '', self.on_pad_release) - - # Icons - self.empty_icon = tkinter.PhotoImage() - self.mode_icon = [[] for i in range(9)] - self.state_icon = [[] for i in range(9)] - - for columns in range(1, 9): - column_width = self.width / columns - row_height = self.height / columns - # Not sure this is right - should be a ImageTk.PhotoImage - lst = [self.empty_icon] - iconsize = (int(column_width * 0.22), int(row_height * 0.2)) - for f in ["zynpad_mode_oneshot", "zynpad_mode_loop", "zynpad_mode_oneshotall", "zynpad_mode_loopall", "zynpad_mode_oneshotsync", "zynpad_mode_loopsync"]: - img = Image.open(f"{self.ui_dir}/icons/{f}.png") - lst.append(ImageTk.PhotoImage(img.resize(iconsize))) - self.mode_icon[columns] = lst.copy() - iconsize = (int(row_height * 0.18), int(row_height * 0.18)) - lst = [] - for f in ["stopped", "playing", "stopping", "starting"]: - img = Image.open(f"{self.ui_dir}/icons/{f}.png") - lst.append(ImageTk.PhotoImage(img.resize(iconsize))) - self.state_icon[columns] = lst.copy() - - # Function to clear and calculate grid sizes - def update_grid(self): - self.redrawing = True - self.column_width = self.width / self.zynseq.col_in_bank - self.row_height = self.height / self.zynseq.col_in_bank - - # Update pads location / size - fs1 = int(self.row_height * 0.15) - fs2 = int(self.row_height * 0.11) - self.grid_canvas.itemconfig("pad", state=tkinter.HIDDEN) - self.update_selection_cursor() - for col in range(self.zynseq.col_in_bank): - pad_x = int(col * self.column_width) - for row in range(self.zynseq.col_in_bank): - pad_y = int(row * self.row_height) - pad = row + col * self.zynseq.col_in_bank - header_h = int(0.28 * self.row_height) - self.grid_canvas.itemconfig(self.pads[pad]["group"], font=(zynthian_gui_config.font_family, fs2)) - self.grid_canvas.itemconfig(self.pads[pad]["num"], font=(zynthian_gui_config.font_family, fs2)) - self.grid_canvas.itemconfig(self.pads[pad]["title"], width=int(0.96 * self.column_width), font=(zynthian_gui_config.font_family, fs1)) - self.grid_canvas.itemconfig(f"pad:{pad}", state=tkinter.NORMAL) - self.grid_canvas.coords(self.pads[pad]["header"], pad_x, pad_y, pad_x + self.column_width - 2, pad_y + header_h) - self.grid_canvas.coords(self.pads[pad]["body"], pad_x, pad_y + header_h, pad_x + self.column_width - 2, pad_y + self.row_height - 2) - posx = pad_x + int(0.02 * self.column_width) - posy = pad_y + int(0.04 * self.row_height) - self.grid_canvas.coords(self.pads[pad]["mode"], posx + int(0.125), posy) - posy = pad_y + int(0.05 * self.row_height) - self.grid_canvas.coords(self.pads[pad]["group"], posx + int(3 * 0.125 * self.column_width), posy) - self.grid_canvas.coords(self.pads[pad]["num"], posx + int(5 * 0.125 * self.column_width), posy) - self.grid_canvas.coords(self.pads[pad]["state"], posx + int(7 * 0.125 * self.column_width), posy) - posx = pad_x + int(0.03 * self.column_width) - self.grid_canvas.coords(self.pads[pad]["title"], posx, posy + 2 * fs1) - - self.redrawing = False - self.columns = self.zynseq.col_in_bank - - # Function to refresh pad if it has changed - # pad: Pad index - # mode: Play mode - # state: Play state - # group: Sequence group - def refresh_pad(self, pad, mode=None, state=None, group=None): - if pad > 63: - return - # Get pad info if needed - if mode is None or state is None or group is None: - state = self.zynseq.libseq.getSequenceState(self.zynseq.bank, pad) - mode = (state >> 8) & 0xFF - group = (state >> 16) & 0xFF - state &= 0xFF - if state == zynseq.SEQ_RESTARTING: - state = zynseq.SEQ_PLAYING - elif state == zynseq.SEQ_STOPPINGSYNC: - state = zynseq.SEQ_STOPPING - - foreground = "white" - cellh = self.pads[pad]["header"] - cellb = self.pads[pad]["body"] - if self.zynseq.libseq.getSequenceLength(self.bank, pad) == 0 or mode == zynseq.SEQ_DISABLED: - self.grid_canvas.itemconfig( - cellh, fill=zynthian_gui_config.PAD_COLOUR_DISABLED) - self.grid_canvas.itemconfig( - cellb, fill=zynthian_gui_config.PAD_COLOUR_DISABLED_LIGHT) - else: - self.grid_canvas.itemconfig( - cellh, fill=zynthian_gui_config.PAD_COLOUR_GROUP[group % 16]) - self.grid_canvas.itemconfig( - cellb, fill=zynthian_gui_config.PAD_COLOUR_GROUP_LIGHT[group % 16]) - if self.zynseq.libseq.getSequenceLength(self.bank, pad) == 0: - mode = 0 - group = chr(65 + group) - # patnum = self.zynseq.libseq.getPatternAt(self.bank, pad, 0, 0) - midi_chan = self.zynseq.libseq.getChannel(self.bank, pad, 0) - title = self.zynseq.get_sequence_name(self.bank, pad) - try: - int(title) # Test for default (integer index) - #title = self.chain_manager.get_synth_preset_name(midi_chan) - # Get from first chain on this MIDI channel - for chain_id, chain in self.chain_manager.chains.items(): - if chain.midi_chan == midi_chan: - title = chain.get_title() - #title = chain.get_description(2) - except: - pass - self.grid_canvas.itemconfig(self.pads[pad]["title"], text=title, fill=foreground) - self.grid_canvas.itemconfig(self.pads[pad]["group"], text=f"CH{midi_chan + 1}", fill=foreground) - self.grid_canvas.itemconfig(self.pads[pad]["num"], text=f"{group}{pad+1}", fill=foreground) - self.grid_canvas.itemconfig(self.pads[pad]["mode"], image=self.mode_icon[self.zynseq.col_in_bank][mode]) - if state == 0 and self.zynseq.libseq.isEmpty(self.bank, pad): - self.grid_canvas.itemconfig(self.pads[pad]["state"], image=self.empty_icon) - else: - self.grid_canvas.itemconfig(self.pads[pad]["state"], image=self.state_icon[self.zynseq.col_in_bank][state]) - - def update_play_state(self, bank, seq, state, mode, group): - if bank == self.bank: - self.refresh_pad(seq, mode=mode, state=state, group=group) - - def update_progress(self, bank, seq, progress): - if bank == self.bank: - x0 = int(seq / self.columns) * self.column_width - y0 = (seq % self.columns + 1) * self.row_height - 8 - x1 = x0 + int(progress * self.column_width / 100) - y1 = y0 + 4 - self.grid_canvas.coords(self.pads[seq]["progress"], x0, y0, x1, y1) - - # ------------------------------------------------------------------------------------------------------------------ - # Some useful functions - # ------------------------------------------------------------------------------------------------------------------ - - def set_bank(self, bank): - self.refresh_trigger_params() - self.zynseq.select_bank(bank) - self.refresh_status(force=True) - - # Function to move selection cursor - def update_selection_cursor(self): - # TODO: Was update_selection_cursor removed during refactor and replaced during merge? - if self.selected_pad >= self.zynseq.libseq.getSequencesInBank(self.bank): - self.selected_pad = self.zynseq.libseq.getSequencesInBank( - self.bank) - 1 - col = int(self.selected_pad / self.zynseq.col_in_bank) - row = self.selected_pad % self.zynseq.col_in_bank - self.grid_canvas.coords(self.selection, - 1 + col * self.column_width, 1 + row * self.row_height, - (1 + col) * self.column_width - self.select_thickness, (1 + row) * self.row_height - self.select_thickness) - self.grid_canvas.tag_raise(self.selection) - - # Function to handle pad press - def on_pad_press(self, event): - tags = self.grid_canvas.gettags( - self.grid_canvas.find_withtag(tkinter.CURRENT)) - pad = int(tags[0].split(':')[1]) - self.select_pad(pad) - if self.param_editor_zctrl: - self.disable_param_editor() - self.grid_timer = Timer( - zynthian_gui_config.zynswitch_bold_seconds, self.on_grid_timer) - self.grid_timer.start() - - # Function to handle pad release - def on_pad_release(self, event): - if self.grid_timer.is_alive(): - self.toggle_pad() - self.grid_timer.cancel() - - # Function to toggle pad - def toggle_pad(self): - self.zynseq.libseq.togglePlayState(self.bank, self.selected_pad) - - # Function to handle grid press and hold - def on_grid_timer(self): - self.gridDragStart = None - self.show_pattern_editor() - - # Function to add menus - def show_menu(self): - self.disable_param_editor() - options = {} - - # Global Options - if not zynthian_gui_config.check_wiring_layout(["Z2", "V5"]): - options[f'Tempo ({self.zynseq.libseq.getTempo():0.1f})'] = 'Tempo' - options[f'Scene ({self.bank})'] = 'Scene' - if not zynthian_gui_config.check_wiring_layout(["Z2"]): - options['Arranger'] = 'Arranger' - options[f'Beats per bar ({self.zynseq.libseq.getBeatsPerBar()})'] = 'Beats per bar' - options[f'Grid size ({self.zynseq.col_in_bank}x{self.zynseq.col_in_bank})'] = 'Grid size' - # Single Pad Options - options['> PAD OPTIONS'] = None - options[f'Play mode ({zynseq.PLAY_MODES[self.zynseq.libseq.getPlayMode(self.bank, self.selected_pad)]})'] = 'Play mode' - options[f'MIDI channel ({1 + self.zynseq.libseq.getChannel(self.bank, self.selected_pad, 0)})'] = 'MIDI channel' - track_type = self.zynseq.libseq.getTrackType( - self.bank, self.selected_pad, 0) - if track_type == 0: - options[f'Track type (MIDI)'] = 'Track type' - elif track_type == 1: - options[f'Track type (Audio)'] = 'Track type' - else: - options[f'Track type (Unknown)'] = 'Track type' - # options['> Misc'] = None - options['Rename'] = 'Rename' - # Trigger Options - options['> TRIGGER OPTIONS'] = None - options[f'Trigger device ({self.get_trigger_device_name()})'] = 'Trigger device' - if self.trigger_device is not None: - options[f'Trigger channel ({self.get_trigger_channel_name()})'] = 'Trigger channel' - if self.trigger_device is not None and self.trigger_channel is not None: - options[f'Trigger note ({self.get_trigger_note_name()})'] = 'Trigger note' - - self.zyngui.screens['option'].config( - "ZynPad Menu", options, self.menu_cb) - self.zyngui.show_screen('option') - - def toggle_menu(self): - if self.shown: - self.show_menu() - elif self.zyngui.current_screen == "option": - self.close_screen() - - def menu_cb(self, option, params): - if params == 'Tempo': - self.zyngui.show_screen('tempo') - elif params == 'Arranger': - self.zyngui.show_screen('arranger') - elif params == 'Beats per bar': - self.enable_param_editor(self, 'bpb', {'name': 'Beats per bar', 'value_min': 1, - 'value_max': 64, 'value_default': 4, 'value': self.zynseq.libseq.getBeatsPerBar()}) - elif params == 'Scene': - self.enable_param_editor(self, 'bank', { - 'name': 'Scene', 'value_min': 1, 'value_max': 64, 'value': self.bank}) - elif params == 'Grid size': - labels = [] - for i in range(1, 9): - labels.append(f'{i}x{i}') - self.enable_param_editor(self, 'grid_size', { - 'name': 'Grid size', 'labels': labels, 'value': self.zynseq.col_in_bank - 1, 'value_default': 3}, self.set_grid_size) - elif params == 'Play mode': - self.enable_param_editor(self, 'playmode', {'name': 'Play mode', 'labels': zynseq.PLAY_MODES, 'value': self.zynseq.libseq.getPlayMode( - self.zynseq.bank, self.selected_pad), 'value_default': zynseq.SEQ_LOOPALL}, self.set_play_mode) - elif params == 'MIDI channel': - labels = [] - for midi_chan in range(16): - preset_name = self.chain_manager.get_synth_preset_name( - midi_chan) - if preset_name: - labels.append(f"{midi_chan + 1} ({preset_name})") - else: - labels.append(f"{midi_chan + 1}") - self.enable_param_editor(self, 'midi_chan', {'name': 'MIDI channel', 'labels': labels, 'value_default': self.zynseq.libseq.getChannel( - self.bank, self.selected_pad, 0), 'value': self.zynseq.libseq.getChannel(self.bank, self.selected_pad, 0)}) - elif params == 'Track type': - track_type = self.zynseq.libseq.getTrackType( - self.bank, self.selected_pad, 0) - if track_type >= 1: - self.set_track_type(0) - elif track_type == 0: - self.set_track_type(1) - elif params == 'Rename': - self.rename_sequence() - elif params == 'Trigger device': - labels = ["None"] + self.get_trigger_devices() + ["All"] - val = 0 if self.trigger_device is None else self.trigger_device + 1 - self.enable_param_editor(self, 'trigger_dev', { - 'name': 'Trigger device', 'labels': labels, 'value': val}) - elif params == 'Trigger channel': - labels = ["None"] + list(range(1, 17)) + ["All"] - val = 0 if self.trigger_channel is None else self.trigger_channel + 1 - self.enable_param_editor(self, 'trigger_chan', { - 'name': 'Trigger channel', 'labels': labels, 'value': val}) - elif params == 'Trigger note': - self.zyngui.cuia_enable_midi_learn() - - def set_track_type(self, track_type): - pattern = self.zynseq.libseq.getPattern( - self.bank, self.selected_pad, 0, 0) - self.zynseq.libseq.selectPattern(pattern) - # self.zynseq.libseq.setSequence(self.bank, self.selected_pad) - self.zynseq.libseq.setTrackType( - self.bank, self.selected_pad, 0, track_type) - if track_type == 0: - # Set to MIDI track - logging.debug("Setting track type to MIDI") - self.zynseq.libseq.setChainID(self.bank, self.selected_pad, 0, 0) - self.zynseq.libseq.clear() - elif track_type == 1: - # Set to Audio track - logging.debug("Setting track type to Audio") - self.zynseq.libseq.clear() - self.zynseq.libseq.addNote( - 0, 60, 100, self.zynseq.libseq.getSteps(), 0) - # Add a new audio player (zynsampler) chain => IMPROVE! We could choose an existing one... - chain_id = self.chain_manager.add_chain(None, self.zynseq.libseq.getChannel( - self.bank, self.selected_pad, 0), True, False) - self.zynseq.libseq.setChainID( - self.bank, self.selected_pad, 0, chain_id) - processor = self.chain_manager.add_processor(chain_id, "AP") - self.zyngui.current_processor = processor - processor.controllers_dict['beats'].set_value( - self.zynseq.libseq.getBeatsInPattern()) - if len(processor.get_bank_list()) > 1: - self.zyngui.show_screen('bank') - else: - processor.set_bank(0) - processor.load_preset_list() - if len(processor.preset_list) > 1: - self.zyngui.show_screen('preset') - # TODO Send signal to refresh zynpad - - def send_controller_value(self, zctrl): - if zctrl.symbol == 'bank': - self.zynseq.select_bank(zctrl.value) - self.set_title(f"Scene {self.zynseq.bank}") - elif zctrl.symbol == 'tempo': - self.zynseq.set_tempo(zctrl.value) - elif zctrl.symbol == 'metro_vol': - self.zynseq.libseq.setMetronomeVolume(zctrl.value / 100.0) - elif zctrl.symbol == 'bpb': - self.zynseq.libseq.setBeatsPerBar(zctrl.value) - elif zctrl.symbol == 'playmode': - self.set_play_mode(zctrl.value) - elif zctrl.symbol == 'midi_chan': - self.zynseq.set_midi_channel( - self.bank, self.selected_pad, 0, zctrl.value) - self.zynseq.set_group(self.bank, self.selected_pad, zctrl.value) - elif zctrl.symbol == 'trigger_dev': - self.set_trigger_device(zctrl) - elif zctrl.symbol == 'trigger_chan': - self.set_trigger_channel(zctrl) - elif zctrl.symbol == 'trigger_note': - if zctrl.value > 0: - self.zynseq.libseq.setTriggerNote( - self.bank, self.selected_pad, zctrl.value - 1) - else: - self.zynseq.libseq.setTriggerNote( - self.bank, self.selected_pad, 128) - - # Function to set the playmode of the selected pad - def set_play_mode(self, mode): - self.zynseq.set_play_mode(self.bank, self.selected_pad, mode) - - # Function to show the editor (pattern or arranger based on sequence content) - def show_pattern_editor(self): - tracks_in_sequence = self.zynseq.libseq.getTracksInSequence( - self.bank, self.selected_pad) - patterns_in_track = self.zynseq.libseq.getPatternsInTrack( - self.bank, self.selected_pad, 0) - pattern = self.zynseq.libseq.getPattern( - self.bank, self.selected_pad, 0, 0) - if tracks_in_sequence != 1 or patterns_in_track != 1 or pattern == -1: - self.zyngui.screens["arranger"].sequence = self.selected_pad - self.zyngui.toggle_screen("arranger") - return True - self.state_manager.start_busy( - "load_pattern", f"loading pattern {pattern}") - self.zyngui.screens['pattern_editor'].channel = self.zynseq.libseq.getChannel( - self.bank, self.selected_pad, 0) - self.zyngui.screens['pattern_editor'].bank = self.bank - self.zyngui.screens['pattern_editor'].sequence = self.selected_pad - self.zyngui.screens['pattern_editor'].load_pattern(pattern) - self.state_manager.end_busy("load_pattern") - self.zyngui.show_screen("pattern_editor") - return True - - # Function to refresh pads - def refresh_status(self, force=False): - super().refresh_status() - - if self.redrawing and not force: - return - - force |= self.zynseq.bank != self.bank - if force: - self.bank = self.zynseq.bank - self.set_title(f"Scene {self.bank}") - if self.columns != self.zynseq.col_in_bank: - self.update_grid() - for pad in range(self.zynseq.col_in_bank ** 2): - self.refresh_pad(pad) - - # Function to select a pad - # pad: Index of pad to select (Default: refresh existing selection) - def select_pad(self, pad=None): - if pad is not None: - self.selected_pad = pad - if self.selected_pad >= self.zynseq.libseq.getSequencesInBank(self.zynseq.bank): - self.selected_pad = self.zynseq.libseq.getSequencesInBank( - self.zynseq.bank) - 1 - col = int(self.selected_pad / self.columns) - row = self.selected_pad % self.columns - self.grid_canvas.coords(self.selection, - 1 + col * self.column_width, 1 + row * self.row_height, - (1 + col) * self.column_width - self.select_thickness, (1 + row) * self.row_height - self.select_thickness) - self.grid_canvas.tag_raise(self.selection) - - # Function to handle zynpots value change - # encoder: Zynpot index [0..n] - # dval: Zynpot value change - def zynpot_cb(self, encoder, dval): - if super().zynpot_cb(encoder, dval): - return - if encoder == self.ctrl_order[3]: - pad = self.selected_pad + self.zynseq.col_in_bank * dval - col = int(pad / self.zynseq.col_in_bank) - row = pad % self.zynseq.col_in_bank - if col >= self.zynseq.col_in_bank: - col = 0 - row += 1 - pad = row + self.zynseq.col_in_bank * col - elif pad < 0: - col = self.zynseq.col_in_bank - 1 - row -= 1 - pad = row + self.zynseq.col_in_bank * col - if row < 0 or row >= self.zynseq.col_in_bank or col >= self.zynseq.col_in_bank: - return - self.select_pad(pad) - elif encoder == self.ctrl_order[2]: - pad = self.selected_pad + dval - if pad < 0 or pad >= self.zynseq.libseq.getSequencesInBank(self.bank): - return - self.select_pad(pad) - elif encoder == self.ctrl_order[1]: - self.set_bank(self.bank + dval) - elif encoder == self.ctrl_order[0] and zynthian_gui_config.transport_clock_source <= 1: - self.zynseq.update_tempo() - self.zynseq.nudge_tempo(dval) - self.set_title(f"Tempo: {self.zynseq.get_tempo():.1f}", None, None, 2) - - # Function to handle SELECT button press - # type: Button press duration ["S"=Short, "B"=Bold, "L"=Long] - def switch_select(self, type='S'): - if super().switch_select(type): - if self.midi_learn: - self.zyngui.cuia_disable_midi_learn() - return True - if type == 'S': - self.toggle_pad() - elif type == "B": - track_type = self.zynseq.libseq.getTrackType( - self.bank, self.selected_pad, 0) - if track_type == 0: - self.show_pattern_editor() - elif track_type == 1: - chain_id = self.zynseq.libseq.getChainID( - self.bank, self.selected_pad, 0) - self.zyngui.chain_control( - chain_id=chain_id, hmode=self.zyngui.SCREEN_HMODE_ADD) - # Sync number of beats => This is not the right place. This should be reviewed with care!! - try: - self.zyngui.get_current_processor().controllers_dict['beats'].set_value( - self.zynseq.libseq.getBeatsInPattern()) - except Exception as e: - logging.error( - f"Can't sync sampler number of beats! => {e}") - else: - logging.error(f"Unknown track type {track_type}") - - def back_action(self): - if self.midi_learn: - self.zyngui.cuia_disable_midi_learn() - return True - else: - return super().back_action() - - # Function to handle switch press - # switch: Switch index [0=Layer, 1=Back, 2=Snapshot, 3=Select] - # type: Press type ["S"=Short, "B"=Bold, "L"=Long] - # returns True if action fully handled or False if parent action should be triggered - def switch(self, switch, type): - if switch == 0 and type == "S": - self.show_menu() - return True - return False - - # CUIA Actions - - # Function to handle CUIA ARROW_RIGHT - def arrow_right(self): - self.zynpot_cb(self.ctrl_order[3], 1) - - # Function to handle CUIA ARROW_LEFT - def arrow_left(self): - self.zynpot_cb(self.ctrl_order[3], -1) - - # Function to handle CUIA ARROW_UP - def arrow_up(self): - if self.param_editor_zctrl: - self.zynpot_cb(self.ctrl_order[3], 1) - else: - self.zynpot_cb(self.ctrl_order[2], -1) - - # Function to handle CUIA ARROW_DOWN - def arrow_down(self): - if self.param_editor_zctrl: - self.zynpot_cb(self.ctrl_order[3], -1) - else: - self.zynpot_cb(self.ctrl_order[2], 1) - - # ************************************************************************** - # Pad Trigger with MIDI note - # ************************************************************************** - - def refresh_trigger_params(self): - """ - Trigger channel is 0 for none, 1..16 for MIDI channel or 255 for all - """ - trig_chan = self.zynseq.libseq.getTriggerChannel() - if trig_chan == 0: - self.trigger_channel = None - elif trig_chan == 255: - self.trigger_channel = 255 - else: - self.trigger_channel = trig_chan - 1 - trig_dev = self.zynseq.libseq.getTriggerDevice() - if trig_dev == 0: - self.trigger_device = None - elif trig_dev == 255: - self.trigger_device = 255 - else: - self.trigger_device = trig_dev - 1 - - def get_note_name(self, note): - return NOTE_NAMES[note % 12] + str(note // 12 - 1) - - def get_trigger_note_name(self): - trigger_note = self.zynseq.libseq.getTriggerNote( - self.bank, self.selected_pad) - if 0 <= trigger_note < 128: - return self.get_note_name(trigger_note) - else: - return "None" - - # Set the trigger channel from the param editor value (zctrl) - def set_trigger_channel(self, zctrl): - if zctrl.value == 0: - self.trigger_channel = None - self.zynseq.libseq.setTriggerChannel(0) - elif zctrl.value == zctrl.value_max: - self.trigger_channel = 0xFF - self.zynseq.libseq.setTriggerChannel(0xFF) - else: - self.trigger_channel = zctrl.value - 1 - self.zynseq.libseq.setTriggerChannel(zctrl.value) - - def get_trigger_channel_name(self): - if self.trigger_channel is None: - return "None" - elif self.trigger_channel > 15: - return "All" - else: - return str(self.trigger_channel + 1) - - # Returns a device list for the param editor - def get_trigger_devices(self): - res = [] - for idev, port in enumerate(zynautoconnect.devices_in): - if port and idev not in self.zyngui.state_manager.ctrldev_manager.drivers: - res.append(port.aliases[1]) - return res - - # Set the trigger device (zmip) from the param editor value (zctrl) - def set_trigger_device(self, zctrl): - if zctrl.value == 0: - self.trigger_device = None - self.zynseq.libseq.setTriggerDevice(0) - elif zctrl.value == zctrl.value_max: - self.trigger_device = 0xFF - self.zynseq.libseq.setTriggerDevice(0xFF) - else: - count = 1 - for idev, port in enumerate(zynautoconnect.devices_in): - if port and idev not in self.zyngui.state_manager.ctrldev_manager.drivers: - if count == zctrl.value: - self.trigger_device = idev - self.zynseq.libseq.setTriggerDevice(idev + 1) - break - count += 1 - - def get_trigger_device_name(self): - if self.trigger_device is None: - return "None" - elif self.trigger_device == 0xFF: - return "All" - else: - try: - return zynautoconnect.devices_in[self.trigger_device].aliases[1] - except: - return "None" - - def enter_midi_learn(self): - self.midi_learn = True - labels = ['None'] - for note in range(128): - labels.append(self.get_note_name(note)) - value = self.zyngui.state_manager.zynseq.libseq.getTriggerNote( - self.bank, self.selected_pad) - if value > 127: - value = 0 - else: - value += 1 - self.enable_param_editor(self, 'trigger_note', { - 'name': 'Trigger note', 'labels': labels, 'value': value}) - - def exit_midi_learn(self): - self.midi_learn = False - self.disable_param_editor() - - def cb_midi_note_on(self, izmip, chan, note, vel): - """Handle MIDI_NOTE_ON signal - - izmip : MIDI input device index - chan : MIDI channel - note : Note number - vel : Velocity value - """ - if izmip > 23 or self.trigger_device is None or (self.trigger_device != 0xFF and self.trigger_device != izmip): - return False - if vel == 0 or self.trigger_channel is None or (self.trigger_channel < 16 and self.trigger_channel != chan): - return False - if self.midi_learn: - self.zynseq.libseq.setTriggerNote( - self.bank, self.selected_pad, note) - self.exit_midi_learn() - return True - else: - sequence = self.zynseq.libseq.getTriggerSequence(note) - if sequence > 0: - self.zynseq.libseq.togglePlayState(self.bank, sequence) - return True - -# ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_widget_GxGraphicEQ.py b/zyngui/zynthian_widget_GxGraphicEQ.py new file mode 100644 index 000000000..afd16abcc --- /dev/null +++ b/zyngui/zynthian_widget_GxGraphicEQ.py @@ -0,0 +1,191 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian GUI +# +# Zynthian Widget Class GxGraphicEQ (11 bands) +# It could be easily extended to support GxBarkGraphicEQ (24 bands). +# +# Copyright (C) 2015-2026 Fernando Moyano +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import tkinter +import logging + +# Zynthian specific modules +from zyngui import zynthian_gui_config +from zyngui import zynthian_widget_base + +# ------------------------------------------------------------------------------ +# Zynthian Widget Class for "GxGraphicEQ" (11 band Graphic EQ) +# ------------------------------------------------------------------------------ + + +class zynthian_widget_GxGraphicEQ(zynthian_widget_base.zynthian_widget_base): + + def __init__(self, parent): + super().__init__(parent) + + # Plot related arrays + self.n_bands = 0 + self.band_freqs = [] + self.band_labels = [] + self.mon_bars = [] + self.mon_ticks = [] + self.mon_labels = [] + + # Geometry vars - set accurately by sizer + self.bar_width = 1 + self.tick_height = 1 + self.padx = 1 + self.font_labels = ("monoid", 8) + + self.widget_canvas = tkinter.Canvas(self, + bd=0, + highlightthickness=0, + relief='flat', + bg=zynthian_gui_config.color_bg) + self.widget_canvas.grid(sticky='news') + self.widget_canvas.bind('', self.on_canvas_press) + self.widget_canvas.bind('', self.on_canvas_drag) + self.widget_canvas.bind("", self.on_canvas_release) + + def create_gui(self): + # Clean canvas + self.widget_canvas.delete() + # Create custom GUI elements: bars & ticks + for i in range(self.n_bands): + self.mon_bars.append(self.widget_canvas.create_rectangle( + 0, 0, 0, 0, fill=zynthian_gui_config.color_hl)) + self.mon_ticks.append(self.widget_canvas.create_rectangle( + 0, 0, 0, 0, fill=zynthian_gui_config.color_on)) + self.mon_labels.append(self.widget_canvas.create_text( + 0, 0, + fill=zynthian_gui_config.color_tx, + text=self.band_labels[i], + angle=90, + anchor="w", + font=self.font_labels)) + # Plot dotted line at Gain 0 + y = self.height - int(30 * self.height / 52) + self.hline_zero = self.widget_canvas.create_line(0, y, self.width, y, + fill=zynthian_gui_config.color_tx_off, + dash=[4, 4]) + + def on_size(self, event): + if event.width == self.width and event.height == self.height: + return + super().on_size(event) + self.widget_canvas.configure(width=self.width, height=self.height) + self.update_geometry() + + def set_processor(self, processor): + super().set_processor(processor) + match processor.engine.plugin_url: + case "http://guitarix.sourceforge.net/plugins/gx_graphiceq_#_graphiceq_": + self.band_freqs = [31, 62, 125, 250, 500, 1000, 2000, 4000, + 8000, 16000, 20000] + self.band_labels = [">31Hz", "62Hz", "125Hz", "250Hz", "500Hz", + "1KHz", "2KHz", "4KHz", "8KHz", "16KHz", "<20KHz"] + case "http://guitarix.sourceforge.net/plugins/gx_barkgraphiceq_#_barkgraphiceq_": + self.band_freqs = [50, 150, 250, 350, 450, 570, 700, 840, 1000, + 1170, 1370, 1600, 1850, 2150, 2500, 2900, 3400, + 4000, 4800, 5800, 7000, 8500, 10500, 13500] + self.band_labels = ["50Hz", "150Hz", "250Hz", "350Hz", "450Hz", "570Hz", + "700Hz", "840Hz", "1K", "1.17K", "1.37K", "1.6K", + "1.85K", "2.15K", "2.5K", "2.9K", "3.4K", "4K", + "4.8K", "5.8K", "7K", "8.5K", "10.5K", "13.5K"] + case _: + return + self.n_bands = len(self.band_freqs) + self.create_gui() + self.update_geometry() + + def update_geometry(self): + if self.n_bands == 0: + return + # Geometry vars + if self.wide: + w = self.width + else: + w = self.width + 2 + self.bar_width = int(w / self.n_bands) + self.tick_height = int(self.height / 80) + self.padx = int((w % self.n_bands) / 2) + self.font_labels_size = int(0.3 * self.bar_width) + self.font_labels = ("monoid", self.font_labels_size) + fpad = self.font_labels_size // 4 + x = self.padx + self.bar_width // 2 + # Update labels + for i in range(self.n_bands): + self.widget_canvas.itemconfig(self.mon_labels[i], font=self.font_labels) + self.widget_canvas.coords(self.mon_labels[i], x, self.height - fpad) + x += self.bar_width + # Updated dotted line at Gain 0 + y = self.height - int(30 * self.height / 52) + self.widget_canvas.coords(self.hline_zero, 0, y, self.width, y) + + def refresh_gui(self): + x0 = self.padx + x1 = x0 + self.bar_width + y0 = self.height - int(30 * self.height / 52) + for i in range(self.n_bands): + try: + val = self.monitors[f"V{i + 1}"] + # -40.0 <= val <= 4.0 + if self.n_bands == 24: + bar_y0 = y0 + bar_y1 = self.height - int((30 + val) * self.height / 52) + #logging.debug(f"V{i + 1} = {val} ") + # 0 <= val <= 1.0 + else: + bar_y0 = self.height + bar_y1 = self.height - int(val * self.height) + + except: + bar_y0 = bar_y1 = 0 + try: + gain = self.processor.controllers_dict[f"G{i + 1}"].value + #logging.debug(f"G{i + 1} = {gain}") + tick_y0 = self.height - int((30 + gain) * self.height / 52) + tick_y1 = tick_y0 - self.tick_height + except: + tick_y0 = tick_y1 = 0 + + self.widget_canvas.coords(self.mon_bars[i], x0 + 2, bar_y0, x1 - 2, bar_y1) + self.widget_canvas.coords(self.mon_ticks[i], x0, tick_y0, x1, tick_y1) + x0 = x1 + x1 += self.bar_width + + + def on_canvas_press(self, event): + self.on_canvas_drag(event) + + def on_canvas_drag(self, event): + n = (event.x + self.bar_width // 2) // self.bar_width + try: + drag_zctrl = self.processor.controllers_dict[f"G{n}"] + except: + return + gain = 52 * (self.height - event.y) // self.height - 30 + drag_zctrl.set_value(gain) + + def on_canvas_release(self, event): + pass + +# ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_widget_audio_file.py b/zyngui/zynthian_widget_audio_file.py index 126b020ba..784857930 100644 --- a/zyngui/zynthian_widget_audio_file.py +++ b/zyngui/zynthian_widget_audio_file.py @@ -59,15 +59,19 @@ def __init__(self, parent): self.samplerate = None self.duration = 0.0 - self.refreshing = False + self.refreshing = False # Flag to avoid multiple threads refreshing waveform self.refresh_waveform = False # True to force redraw of waveform on next refresh self.waveform_height = 1 # ratio of height for y offset of zoom overview display self.offset = 0 # Frames from start of file that waveform display starts + self.auto_offset = 0 # 1 to calc offset from crop_start. 2 to calc offest from crop_end. self.zoom = 1 self.v_zoom = 1 + self.crop_start = 0 + self.crop_end = 0 self.bg_color = zynthian_gui_config.color_bg self.waveform_color = zynthian_gui_config.color_info + self.bg_crop_color = zynthian_gui_config.color_variant(zynthian_gui_config.color_panel_bg, 30) self.font_info = tkinter.font.Font(font=("DejaVu Sans Mono", int(1.0 * zynthian_gui_config.font_size))) self.widget_canvas = tkinter.Canvas(self, @@ -85,6 +89,18 @@ def __init__(self, parent): fill=zynthian_gui_config.color_tx_off, text="No file loaded" ) + self.crop_start_rect = self.widget_canvas.create_rectangle( + 0, 0, 0, self.height, + fill=self.bg_crop_color, + stipple="gray50", + tags="overlay" + ) + self.crop_end_rect = self.widget_canvas.create_rectangle( + self.width, 0, self.width, self.height, + fill=self.bg_crop_color, + stipple="gray50", + tags="overlay" + ) self.info_rect = self.widget_canvas.create_rectangle( 0, self.height, @@ -169,24 +185,16 @@ def load_file(self): # fill = zynthian_gui_config.LAUNCHER_COLOUR[chan // 2 % 16]["rgb"] self.widget_canvas.create_line(0, v_offset + y0 // 2, self.width, v_offset + y0 // 2, fill="grey", tags="waveform", state=tkinter.HIDDEN) self.widget_canvas.create_line(0, 0, 0, 0, fill=self.waveform_color, tags=("waveform", f"waveform{chan}"), state=tkinter.HIDDEN) - #self.update_cue_markers() - frames = self.frames / 2 - labels = ['x1'] - values = [1] - z = 1 - while frames > self.width: - z *= 2 - labels.append(f"x{z}") - values.append(z) - frames /= 2 - #zctrl = self.processor.controllers_dict['zoom'] - #zctrl.set_options({'labels': labels, 'ticks': values, 'value_max': values[-1]} - #self.draw_waveform(0, self.frames) + self.offset = 0 + self.auto_offset = 0 + self.crop_start = 0 + self.crop_end = self.frames except MemoryError: logging.warning(f"Failed to show waveform - file too large") self.widget_canvas.itemconfig(self.loading_text, text="Can't display waveform") self.sf = None except Exception as e: + logging.warning(f"Failed to show waveform: {e}") self.widget_canvas.itemconfig(self.loading_text, text="No file loaded", state=tkinter.NORMAL) self.sf = None self.refreshing = False @@ -197,8 +205,6 @@ def load_file(self): self.widget_canvas.itemconfig(self.loading_text, text="No file loaded", state=tkinter.NORMAL) self.sf = None - self.update() - def draw_waveform(self, start, length): if self.sf is None: self.widget_canvas.itemconfig(f"waveform", state=tkinter.HIDDEN) @@ -206,8 +212,8 @@ def draw_waveform(self, start, length): self.widget_canvas.itemconfig(self.loading_text, text="No file loaded", state=tkinter.NORMAL) return - start = min(self.frames, max(0, start)) - length = min(self.frames - start, length) + length = min(self.frames, length) + start = min(start, (self.frames - length)) steps_per_peak = 16 data = [[] for i in range(self.channels)] large_file = self.frames * self.channels > 24000000 @@ -231,16 +237,11 @@ def draw_waveform(self, start, length): else: self.sf.seek(start) a_data = self.sf.read(length, always_2d=True) - frames_per_pixel = len(a_data) // self.width - step = max(1, frames_per_pixel // steps_per_peak) + frames_per_pixel = len(a_data) / self.width + step = max(1, frames_per_pixel / steps_per_peak) # Limit read blocks for larger files block_size = min(frames_per_pixel, 1024) - if frames_per_pixel < 1: - self.refresh_waveform = False - self.widget_canvas.itemconfig(self.loading_text, text="Audio too short") - return - v1 = [0.0 for i in range(self.channels)] v2 = [0.0 for i in range(self.channels)] @@ -258,13 +259,15 @@ def draw_waveform(self, start, length): # For each audio channel v1[0:] = [0.0] * self.channels v2[0:] = [0.0] * self.channels - for frame in range(offset1, offset2, step): + frame = offset1 + while int(frame) < int(offset2): # Find peak audio within block of audio represented by this x-axis pixel - av = a_data[frame][channel] * self.v_zoom + av = a_data[int(frame)][channel] * self.v_zoom if av < v1[channel]: v1[channel] = av if av > v2[channel]: v2[channel] = av + frame += step data[channel] += (x, y_offsets[channel] + int(v1[channel] * y0), x, y_offsets[channel] + int(v2[channel] * y0)) @@ -278,10 +281,11 @@ def draw_waveform(self, start, length): self.widget_canvas.itemconfig(f"overlay", state=tkinter.NORMAL) def refresh_gui(self): - if self.refreshing: - return + #if self.refreshing: + # return self.refreshing = True refresh_info = False + update_markers = False try: if self.zctrl != self.zyngui_control.widget_zctrl: @@ -290,8 +294,36 @@ def refresh_gui(self): self.refreshing = False return + if "zoom" in self.monitors and self.zoom != self.monitors["zoom"]: + self.zoom = self.monitors["zoom"] + self.refresh_waveform = True + + if "offset" in self.monitors: + if self.offset != self.monitors["offset"]: + self.offset = self.monitors["offset"] + self.refresh_waveform = True + self.auto_offset = 0 + else: + if self.auto_offset == 0: + self.auto_offset = 1 + + if "crop_start" in self.monitors and self.crop_start != self.monitors["crop_start"]: + self.crop_start = self.monitors["crop_start"] + update_markers = True + self.refresh_waveform = True + if self.auto_offset: + self.auto_offset = 1 + + if "crop_end" in self.monitors and self.crop_end != self.monitors["crop_end"]: + self.crop_end = self.monitors["crop_end"] + update_markers = True + self.refresh_waveform = True + if self.auto_offset: + self.auto_offset = 2 + try: if self.zctrl and self.fpath != self.zctrl.value: + # Audio file changed so reload waveform from file audio data self.fpath = self.zctrl.value self.fname = basename(self.fpath) waveform_thread = Thread(target=self.load_file, name="waveform image") @@ -299,10 +331,30 @@ def refresh_gui(self): return if self.refresh_waveform: - self.draw_waveform(self.offset, int(self.frames / self.zoom)) + length = self.frames // self.zoom + if self.auto_offset == 1: + # Centre on start crop marker + self.offset = self.crop_start - length // 2 + elif self.auto_offset == 2: + # Centre on end crop marker + self.offset = self.crop_end - length // 2 + # Ensure whoe waveform can be drawn + self.offset = min(self.offset, self.frames - length) + self.offset = max(self.offset, 0) + self.draw_waveform(self.offset, length) refresh_info = True + update_markers = True self.refresh_waveform = False + if update_markers and self.frames: + h = self.waveform_height + f = self.width / self.frames * self.zoom + x = int(f * (self.crop_start - self.offset)) + self.widget_canvas.coords(self.crop_start_rect, 0, 0, x, h) + x = int(f * (self.crop_end - self.offset)) + self.widget_canvas.coords(self.crop_end_rect, x, 0, self.width, h) + refresh_info = True + if refresh_info: time = self.duration n = (self.width // self.font_info.measure("x")) - 12 diff --git a/zyngui/zynthian_widget_audioplayer.py b/zyngui/zynthian_widget_audioplayer.py index 77a961111..b0ba11d7c 100644 --- a/zyngui/zynthian_widget_audioplayer.py +++ b/zyngui/zynthian_widget_audioplayer.py @@ -4,7 +4,7 @@ # # Zynthian Widget Class for "Zynthian Audio Player" (zynaudioplayer#one) # -# Copyright (C) 2015-2024 Fernando Moyano +# Copyright (C) 2015-2025 Fernando Moyano # Brian Walton # # ****************************************************************************** @@ -35,7 +35,6 @@ from zynlibs.zynaudioplayer import * from zyngui import zynthian_gui_config from zyngui import zynthian_widget_base -from zyngui import zynthian_gui_config from zyngui.multitouch import MultitouchTypes # ------------------------------------------------------------------------------ @@ -431,7 +430,7 @@ def load_file(self): v_offset = chan * y0 pair = chan // 2 fill = bg_colors[pair % 2] - # fill=zynthian_gui_config.PAD_COLOUR_GROUP[chan // 2 % len(zynthian_gui_config.PAD_COLOUR_GROUP)] + # fill = zynthian_gui_config.LAUNCHER_COLOUR[chan // 2 % 16]["rgb"] self.widget_canvas.create_rectangle(0, v_offset, self.width, v_offset + y0, width=0, fill=fill, tags=("waveform", f"waveform_bg_{chan}"), state=tkinter.HIDDEN) self.widget_canvas.create_line(0, v_offset + y0 // 2, self.width, v_offset + y0 // 2, fill="grey", tags="waveform", state=tkinter.HIDDEN) self.widget_canvas.create_line(0, 0, 0, 0, fill=self.waveform_color, tags=("waveform", f"waveform{chan}"), state=tkinter.HIDDEN) diff --git a/zyngui/zynthian_widget_base.py b/zyngui/zynthian_widget_base.py index d83073b35..89e9f237f 100644 --- a/zyngui/zynthian_widget_base.py +++ b/zyngui/zynthian_widget_base.py @@ -52,14 +52,22 @@ def __init__(self, parent): self.bind('', self.on_size) def on_size(self, event): + """ Handle GUI layout change + + Parameters: + event - size event + Returns: True if size changed + """ + if event.width == self.width and event.height == self.height: - return + return False self.width = event.width self.height = event.height try: self.widget_canvas.configure(width=self.width, height=self.height) except: pass + return True def show(self): if not self.shown: diff --git a/zyngui/zynthian_widget_envelope.py b/zyngui/zynthian_widget_envelope.py index 8f4ba3997..e3e58c377 100644 --- a/zyngui/zynthian_widget_envelope.py +++ b/zyngui/zynthian_widget_envelope.py @@ -41,7 +41,7 @@ def __init__(self, parent): super().__init__(parent) # Take only half height - self.rows //= 2 + self.rows //= 2 self.widget_canvas = tkinter.Canvas(self, highlightthickness=0, @@ -53,8 +53,8 @@ def __init__(self, parent): self.drag_zctrl = None # Create custom GUI elements (position and size set when canvas is grid and size applied) - self.envelope_outline_color = zynthian_gui_config.color_low_on - self.envelope_color = zynthian_gui_config.color_variant(zynthian_gui_config.color_low_on, -70) + self.envelope_outline_color = zynthian_gui_config.color_on + self.envelope_color = zynthian_gui_config.color_variant(zynthian_gui_config.color_on, -90) self.envelope_polygon = self.widget_canvas.create_polygon(0, 0, outline=self.envelope_outline_color, fill=self.envelope_color, width=3) self.drag_polygon = self.widget_canvas.create_polygon(0, 0, outline=self.envelope_outline_color, fill=self.envelope_outline_color, width=3, state='hidden') # self.release_line = self.widget_canvas.create_line(0, 0, 0, 0, @@ -168,8 +168,7 @@ def refresh_gui(self): self.last_envelope_values = envelope_values # Highlight dragged section if self.drag_zctrl: - self.widget_canvas.itemconfig( - self.drag_polygon, state="normal") + self.widget_canvas.itemconfig(self.drag_polygon, state="normal") def on_canvas_press(self, event): self.last_click = event diff --git a/zyngui/zynthian_widget_filter.py b/zyngui/zynthian_widget_filter.py new file mode 100644 index 000000000..098e74bd5 --- /dev/null +++ b/zyngui/zynthian_widget_filter.py @@ -0,0 +1,218 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian GUI +# +# Zynthian Widget Class for filter screen type +# +# Copyright (C) 2025 Ronald Summers +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# ****************************************************************************** + + +import tkinter +import math +from zyngui import zynthian_gui_config +from zyngui import zynthian_widget_base + + +class zynthian_widget_filter(zynthian_widget_base.zynthian_widget_base): + + def __init__(self, parent): + super().__init__(parent) + self.fg_color = zynthian_gui_config.color_tx + self.font_small = ("sans", 10) + + + # Take only half height + self.rows //= 2 + + self.widget_canvas = tkinter.Canvas(self, highlightthickness=0, relief='flat', bg=zynthian_gui_config.color_bg) + self.widget_canvas.grid(sticky='news') + + # Theme Colors + self.fill_color = zynthian_gui_config.color_variant(zynthian_gui_config.color_on, -90) + self.outline_color = zynthian_gui_config.color_on + self.grid_color = zynthian_gui_config.color_hl + + # Persistent Polygon for performance + self.filter_poly = self.widget_canvas.create_polygon(0, 0, fill=self.fill_color, outline=self.outline_color, width=2) + + self.grid_items = [] + self.label_items = [] + + # XY-Pad Bindings + self.widget_canvas.bind('', self.on_canvas_press) + self.widget_canvas.bind('', self.on_canvas_drag) + self.widget_canvas.bind("", self.on_canvas_release) + + self.cutoff_param = None + self.resonance_param = None + self.last_values = [] + + self.click_cutoff_val = 0.5 + self.click_res_val = 0.0 + self.is_dragging = False + + # Vertical Scale Configuration (-48 to +24 dB) + #self.db_min = -48 + #self.db_max = 24 + #self.db_range = self.db_max - self.db_min + + # Vertical Scale Configuration (-24 to +18 dB) + self.db_min = -32 + self.db_max = 16 + self.db_range = self.db_max - self.db_min + + # Margins + self.m_l, self.m_r, self.m_t, self.m_b = 45, 10, 20, 35 + + self.draw_grid() + + def show(self): + for zctrl in self.processor.get_group_zctrls(self.zyngui_control.screen_info[0]): + try: + if zctrl.filter == "cutoffFrequency": + self.cutoff_param = zctrl + elif zctrl.filter == "resonance": + self.resonance_param = zctrl + except: + pass + super().show() + + def draw_grid(self): + # 0. Precalculate some geometric values + maxy = self.height - self.m_b + gw, gh = self.width - self.m_l - self.m_r, maxy - self.m_t + if gw < 10: + return + + # Redraw Grid + for item in self.grid_items + self.label_items: + self.widget_canvas.delete(item) + self.grid_items = [] + self.label_items = [] + + # dB Grid Lines + for db in [16, 8, 0, -8, -16, -24, -32]: + #for db in [24, 12, 0, -24, -48]: + y = self.m_t + gh * (1.0 - (db - self.db_min) / self.db_range) + self.grid_items.append(self.widget_canvas.create_line(self.m_l, y, self.width - self.m_r, y, fill=self.grid_color, dash=(2, 2))) + #self.label_items.append(self.widget_canvas.create_text(5, y, text=f"{db} dB", fill=zynthian_gui_config.color_tx, anchor="w", font=("sans", 8))) + + # Evenly spaced vertical gridlines (linear spacing) + num_vlines = 8 # adjust to taste + for i in range(num_vlines + 1): + x = self.m_l + (gw * i / num_vlines) + self.grid_items.append( + self.widget_canvas.create_line( + x, self.m_t, x, self.height - self.m_b, + fill=self.grid_color, dash=(2, 2) + ) + ) + + # Single axis labels + self.widget_canvas.create_text( + 10, 80, + text="dB", + anchor="nw", + fill=self.fg_color, + font=self.font_small + ) + self.widget_canvas.create_text( + self.width - 270, self.height - 10, + text="Hz", + anchor="se", + fill=self.fg_color, + font=self.font_small + ) + + + def on_size(self, event): + super().on_size(event) + self.draw_grid() + self.refresh_gui() + + def refresh_gui(self): + # 1. Normalize for math consistency across engines + norm_cutoff = (self.cutoff_param.value - self.cutoff_param.value_min) / self.cutoff_param.value_range if self.cutoff_param else 0.5 + norm_res = (self.resonance_param.value - self.resonance_param.value_min) / self.resonance_param.value_range if self.resonance_param else 0.0 + if [norm_cutoff, norm_res] == self.last_values and not self.is_dragging: + return + + # 2. Precalculate some geometric values + maxy = self.height - self.m_b + gw, gh = self.width - self.m_l - self.m_r, maxy - self.m_t + if gw < 10: + return + + # 3. SMOOTH CURVE: Calculate point for every horizontal pixel + closed = False + coords = [self.m_l, maxy] + display_cutoff = 20.0 * (20000.0 / 20.0) ** norm_cutoff + for px_offset in range(int(gw) + 1): + log_pos = px_offset / gw + freq = 20.0 * (20000.0 / 20.0) ** log_pos + + f_ratio = freq / max(1.0, display_cutoff) + resp = 1.0 / math.sqrt(1.0 + math.pow(f_ratio, 8)) + db_val = 20.0 * math.log10(resp + 1e-10) # Base is ~ -3dB at cutoff + + # Apply Resonance (Updated to 18.0 for a ~15dB total peak) + if norm_res > 0: + peak = math.exp(-pow(math.log(f_ratio + 1e-10), 2) / 0.05) + #db_val += (norm_res * 18.0 * peak) + db_val += (norm_res * 12.0 * peak) + + px = self.m_l + px_offset + py = self.m_t + gh * (1.0 - (db_val - self.db_min) / self.db_range) + if py < maxy: + coords.extend([px, max(self.m_t, py)]) + else: + coords.extend([px, max(self.m_t, maxy)]) + closed = True + break + + # 4. Close polygon when slope cross 0 beyond display area + if not closed: + coords.extend([self.width - self.m_r, maxy]) + + self.widget_canvas.coords(self.filter_poly, *coords) + self.last_values = [norm_cutoff, norm_res] + + def on_canvas_press(self, event): + self.last_click = event + self.is_dragging = True + if self.cutoff_param: + self.click_cutoff_val = (self.cutoff_param.value - self.cutoff_param.value_min) / self.cutoff_param.value_range + if self.resonance_param: + self.click_res_val = (self.resonance_param.value - self.resonance_param.value_min) / self.resonance_param.value_range + + def on_canvas_drag(self, event): + """Pure XY-Pad: X=Cutoff, Y=Resonance regardless of start position""" + if not self.is_dragging: return + + dx = (event.x - self.last_click.x) / self.width + dy = (event.y - self.last_click.y) / self.height + + if self.cutoff_param: + new_norm_x = max(0.0, min(1.0, self.click_cutoff_val + dx)) + self.cutoff_param.set_value(self.cutoff_param.value_min + (new_norm_x * self.cutoff_param.value_range)) + + if self.resonance_param: + new_norm_y = max(0.0, min(1.0, self.click_res_val - dy)) # Drag up to increase + self.resonance_param.set_value(self.resonance_param.value_min + (new_norm_y * self.resonance_param.value_range)) + + def on_canvas_release(self, event): + self.is_dragging = False diff --git a/zyngui/zynthian_widget_organelle.py b/zyngui/zynthian_widget_organelle.py index 8e5f1f0b3..1cd6901b5 100644 --- a/zyngui/zynthian_widget_organelle.py +++ b/zyngui/zynthian_widget_organelle.py @@ -356,18 +356,26 @@ def __init__(self, parent): self.show_touch_widgets = False self.switch_i_selmode = 19 self.switch_i_aux = 23 + self.wsled_i_selmode = 12 + self.wsled_i_aux = 13 elif zynthian_gui_config.check_wiring_layout(["Z2"]): self.show_touch_widgets = False - self.switch_i_selmode = 9 - self.switch_i_aux = 10 + self.switch_i_selmode = 15 + self.switch_i_aux = 16 + self.wsled_i_selmode = 13 + self.wsled_i_aux = 14 elif zynthian_gui_config.check_kit_version(["V4"]): self.show_touch_widgets = False self.switch_i_selmode = 5 self.switch_i_aux = 4 + self.wsled_i_selmode = None + self.wsled_i_aux = None else: self.show_touch_widgets = True self.switch_i_selmode = None self.switch_i_aux = None + self.wsled_i_selmode = None + self.wsled_i_aux = None #self.show_touch_widgets = True if layout['columns'] == 2: @@ -1005,16 +1013,15 @@ def cuia_arrow_down(self, params=None): return True def update_wsleds(self, leds): - # F3 & F4 wsl = self.zyngui.wsleds if self.selector: if self.select_mode: - #wsl.set_led(leds[12], wsl.wscolor_active2) - wsl.blink(leds[12], wsl.wscolor_active2) + #wsl.set_led(leds[self.wsled_i_selmode], wsl.wscolor_active2) + wsl.blink(leds[self.wsled_i_selmode], wsl.wscolor_active2) else: - wsl.set_led(leds[12], wsl.wscolor_active2) + wsl.set_led(leds[self.wsled_i_selmode], wsl.wscolor_active2) if self.aux_pushed: - wsl.set_led(leds[13], wsl.wscolor_green) + wsl.set_led(leds[self.wsled_i_aux], wsl.wscolor_green) else: - wsl.set_led(leds[13], wsl.wscolor_active2) + wsl.set_led(leds[self.wsled_i_aux], wsl.wscolor_active2) diff --git a/zyngui/zynthian_widget_sooperlooper.py b/zyngui/zynthian_widget_sooperlooper.py index 5de33e6fc..8df0eb749 100644 --- a/zyngui/zynthian_widget_sooperlooper.py +++ b/zyngui/zynthian_widget_sooperlooper.py @@ -46,6 +46,8 @@ class zynthian_widget_sooperlooper(zynthian_widget_base.zynthian_widget_base): def __init__(self, parent): super().__init__(parent) + self.alt_mode = False + self.slider_press_event = None self.state = 0 self.selected_loop = 0 @@ -531,8 +533,15 @@ def refresh_gui(self): # Buttons => 'record', 'overdub', 'multiply', 'replace', 'substitute', 'insert', 'undo', 'redo', 'trigger', 'oneshot', 'reverse', 'pause' + def get_alt_mode(self): + return self.alt_mode + + def cuia_toggle_alt_mode(self, params=None): + self.alt_mode = not self.alt_mode + return True + def cuia_toggle_record(self, params=None): - if self.zyngui.alt_mode: + if self.alt_mode: state = self.monitors['state'] if state in (SL_STATE_REC_STARTING, SL_STATE_RECORDING, SL_STATE_REC_STOPPING): btn = "record" @@ -550,13 +559,13 @@ def cuia_toggle_record(self, params=None): return True def cuia_stop(self, params=None): - if self.zyngui.alt_mode: + if self.alt_mode: state = self.monitors['state'] self.processor.controllers_dict['multiply'].toggle() return True def cuia_toggle_play(self, params=None): - if self.zyngui.alt_mode: + if self.alt_mode: state = self.monitors['state'] if state == SL_STATE_MUTED: self.processor.controllers_dict['mute'].set_value(0, True) @@ -567,27 +576,27 @@ def cuia_toggle_play(self, params=None): return True def cuia_arrow_up(self, params=None): - if self.zyngui.alt_mode: + if self.alt_mode: self.processor.engine.prev_loop() return True def cuia_arrow_down(self, params=None): - if self.zyngui.alt_mode: + if self.alt_mode: self.processor.engine.next_loop() return True def cuia_arrow_left(self, params=None): - if self.zyngui.alt_mode: + if self.alt_mode: self.processor.engine.undo() return True def cuia_arrow_right(self, params=None): - if self.zyngui.alt_mode: + if self.alt_mode: self.processor.engine.redo() return True def cuia_program_change(self, params=None): - if self.zyngui.alt_mode: + if self.alt_mode: if len(params) > 0: pgm = int(params[0]) if pgm == 5: @@ -602,7 +611,7 @@ def cuia_program_change(self, params=None): def update_wsleds(self, leds): # ALT mode only! - if not self.zyngui.alt_mode: + if not self.alt_mode: return wsl = self.zyngui.wsleds color_default = wsl.wscolor_active2 diff --git a/zyngui/zynthian_widget_spectr30.py b/zyngui/zynthian_widget_spectr30.py index 03ed2219c..4ae45f26a 100644 --- a/zyngui/zynthian_widget_spectr30.py +++ b/zyngui/zynthian_widget_spectr30.py @@ -97,16 +97,13 @@ def on_size(self, event): fpad = self.font_labels_size // 4 x = self.padx + fpad for i in range(self.n_bands): - self.widget_canvas.itemconfig( - self.mon_labels[i], font=self.font_labels) - self.widget_canvas.coords( - self.mon_labels[i], x, self.height - fpad) + self.widget_canvas.itemconfig(self.mon_labels[i], font=self.font_labels) + self.widget_canvas.coords(self.mon_labels[i], x, self.height - fpad) x += self.bar_width def refresh_gui(self): try: - scale = ( - 12.0 + self.processor.controllers_dict['UIgain'].value) / 12.0 + scale = (12.0 + self.processor.controllers_dict['UIgain'].value) / 12.0 except: scale = 1.0 @@ -114,21 +111,19 @@ def refresh_gui(self): x0 = self.padx for freq in self.band_freqs: try: - bar_y = int( - scale * (100 + self.monitors["band{}".format(freq)]) * self.height / 100) + bar_y = int(scale * (100 + self.monitors["band{}".format(freq)]) * self.height / 100) except: bar_y = 0 try: - tick_y = int( - scale * (100 + self.monitors["max{}".format(freq)]) * self.height / 100) + tick_y = int(scale * (100 + self.monitors["max{}".format(freq)]) * self.height / 100) except: tick_y = 0 # logging.debug("FREQ {} => {}, {}".format(freq, bar_y, tick_y)) - self.widget_canvas.coords( - self.mon_bars[i], x0, self.height, x0 + self.bar_width, self.height - bar_y) - self.widget_canvas.coords( - self.mon_ticks[i], x0, self.height - tick_y, x0 + self.bar_width, self.height - tick_y - self.tick_height) + self.widget_canvas.coords(self.mon_bars[i], x0, self.height, + x0 + self.bar_width, self.height - bar_y) + self.widget_canvas.coords(self.mon_ticks[i], x0, self.height - tick_y, + x0 + self.bar_width, self.height - tick_y - self.tick_height) x0 += self.bar_width i += 1 diff --git a/zyngui/zynthian_widget_tempo.py b/zyngui/zynthian_widget_tempo.py new file mode 100644 index 000000000..f672892c3 --- /dev/null +++ b/zyngui/zynthian_widget_tempo.py @@ -0,0 +1,86 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ****************************************************************************** +# ZYNTHIAN PROJECT: Zynthian GUI +# +# Zynthian Widget Class for "Tempo" +# +# Copyright (C) 2015-2026 Fernando Moyano +# +# ****************************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ****************************************************************************** + +import tkinter +import logging + +# Zynthian specific modules +from zyngui import zynthian_gui_config +from zyngui import zynthian_widget_base +from zyngine.zynthian_signal_manager import zynsigman +from zynlibs.zynseq.zynseq import SS_SEQ_TEMPO + +# ------------------------------------------------------------------------------ +# Zynthian Widget Class for "Tempo" +# ------------------------------------------------------------------------------ + + +class zynthian_widget_tempo(zynthian_widget_base.zynthian_widget_base): + + def __init__(self, parent): + super().__init__(parent) + + self.widget_canvas = tkinter.Canvas(self, + highlightthickness=0, + relief='flat', + bg=zynthian_gui_config.color_panel_bg) + self.widget_canvas.grid(sticky='news') + + # Create custom GUI elements (position and size set when canvas is grid and size applied) + self.bpm_text = self.widget_canvas.create_text( + 0, 0, + anchor=tkinter.CENTER, + width=0, + text="", + font=(zynthian_gui_config.font_family, 10), + fill=zynthian_gui_config.color_panel_tx + ) + self.widget_canvas.bind("", self.tap) + + def on_size(self, event): + if super().on_size(event): + fs = self.width // 16 + self.widget_canvas.coords(self.bpm_text, self.width // 2, self.height // 2) + self.widget_canvas.itemconfigure(self.bpm_text, width=9 * fs, font=(zynthian_gui_config.font_family, fs)) + + def show(self): + super().show() + try: + self.set_tempo(self.zyngui.state_manager.zynseq.get_tempo()) + except: + pass + zynsigman.register_queued(zynsigman.S_STEPSEQ, SS_SEQ_TEMPO, self.set_tempo) + + def hide(self): + super().hide() + zynsigman.unregister(zynsigman.S_STEPSEQ, SS_SEQ_TEMPO, self.set_tempo) + + def set_tempo(self, tempo): + self.widget_canvas.itemconfigure(self.bpm_text, text=f"{tempo:.1f} BPM") + + def tap(self, event): + self.zyngui.state_manager.zynseq.tap_tempo() + +# ------------------------------------------------------------------------------ diff --git a/zyngui/zynthian_wsleds_base.py b/zyngui/zynthian_wsleds_base.py index b2e4a9131..7490aaa25 100644 --- a/zyngui/zynthian_wsleds_base.py +++ b/zyngui/zynthian_wsleds_base.py @@ -190,18 +190,19 @@ def update(self): logging.exception(traceback.format_exc()) self.wsleds.show() - if self.zyngui.capture_log_fname: + if self.zyngui.capture_log: try: wsled_state = [] for i in range(self.num_leds): c = str(self.get_led(i)) - if c in self.wscolors_dict: + try: wsled_state.append(self.wscolors_dict[c]) + except: + wsled_state.append("0") wsled_state = ",".join(wsled_state) if wsled_state != self.last_wsled_state: self.last_wsled_state = wsled_state - self.zyngui.write_capture_log( - "LEDSTATE:" + wsled_state) + self.zyngui.write_capture_log("LEDSTATE:" + wsled_state) # logging.debug(f"Capturing LED state log => {wsled_state}") except Exception as e: logging.error(f"Capturing LED state log => {e}") diff --git a/zyngui/zynthian_wsleds_v5.py b/zyngui/zynthian_wsleds_v5.py index 6d0b813c1..b5cd4c564 100644 --- a/zyngui/zynthian_wsleds_v5.py +++ b/zyngui/zynthian_wsleds_v5.py @@ -43,11 +43,14 @@ def __init__(self, zyngui): # + arrow => 14, 16, 17, 18 # + BACK/SEL => 15, 13 # + F1-F4 => 4, 11, 12, 19 + # + CTRL => 2 self.custom_wsleds = [7, 8, 9, 10, 14, - 16, 17, 18, 15, 13, 4, 11, 12, 19] + 16, 17, 18, 15, 13, + 4, 11, 12, 19, None, + 2] def update_wsleds(self): - curscreen = self.zyngui.current_screen + curscreen = self.zyngui.get_current_screen() curscreen_obj = self.zyngui.get_current_screen_obj() # Menu / Admin @@ -59,7 +62,7 @@ def update_wsleds(self): self.wsleds[0] = self.wscolor_default # Audio Mixer / ALSA Mixer - if curscreen == "audio_mixer": + if curscreen == "mixer": self.wsleds[1] = self.wscolor_active elif curscreen == "alsa_mixer": self.wsleds[1] = self.wscolor_active2 @@ -72,10 +75,7 @@ def update_wsleds(self): elif curscreen in ("preset", "bank"): self.wsleds[2] = self.wscolor_active2 else: - if self.zyngui.alt_mode: - self.wsleds[2] = self.wscolor_alt - else: - self.wsleds[2] = self.wscolor_default + self.wsleds[2] = self.wscolor_default # ZS3 / Snapshot: if curscreen == "zs3": @@ -85,10 +85,10 @@ def update_wsleds(self): else: self.wsleds[3] = self.wscolor_default - # Zynseq: Zynpad /Pattern Editor - if curscreen == "zynpad": + # Zynseq: Launcher /Pattern Editor / Arranger + if curscreen == "launcher": self.wsleds[5] = self.wscolor_active - elif curscreen == "pattern_editor": + elif curscreen in ("pattern_editor", "pated_cc", "arranger"): self.wsleds[5] = self.wscolor_active2 else: self.wsleds[5] = self.wscolor_default @@ -96,7 +96,7 @@ def update_wsleds(self): # Tempo Screen if curscreen == "tempo": self.wsleds[6] = self.wscolor_active - elif self.zyngui.state_manager.zynseq.libseq.isMetronomeEnabled(): + elif self.zyngui.state_manager.zynseq.libseq.getMetronomeMode() > 0: self.blink(6, self.wscolor_active) else: self.wsleds[6] = self.wscolor_default diff --git a/zyngui/zynthian_wsleds_v5touch.py b/zyngui/zynthian_wsleds_v5touch.py index 0ea8118ae..16aad914e 100644 --- a/zyngui/zynthian_wsleds_v5touch.py +++ b/zyngui/zynthian_wsleds_v5touch.py @@ -33,6 +33,7 @@ # Fake NeoPixel emulation for onscreen touch keypad "buttons" # --------------------------------------------------------------------------- + class touchkeypad_button_colors: """ Fake NeoPixel emulation to change colors of onscreen touch keypad @@ -41,6 +42,7 @@ class touchkeypad_button_colors: def __init__(self, wsleds): self.wsleds = wsleds self.zyngui = wsleds.zyngui + self.led_status = [None] * 20 # A wanna-be abstraction: derive a named "mode" from the requested colors self.mode_map = {} self.mode_map[wsleds.wscolor_default] = 'default' @@ -49,11 +51,12 @@ def __init__(self, wsleds): self.mode_map[wsleds.wscolor_active2] = 'active2' def __setitem__(self, index, color): + self.led_status[index] = color mode = self.mode_map.get(color, None) # request color change on the onscreen touchkeypad if isinstance(color, int): color = f"#{color:06x}" # color conversion to hex cod - # tkinter is not able to set RGBA/alpha color, + # tkinter is not able to set RGBA/alpha color, # so we need to blend the foreground color with the background color if zynthian_gui_config.zyngui: fgcolor = self.hex_to_rgb(color) @@ -62,6 +65,14 @@ def __setitem__(self, index, color): color = self.rgb_to_hex(blended) zynthian_gui_config.touch_keypad.set_button_color(index, color, mode) + + def __getitem__(self, index): + try: + return self.led_status[index] + except: + return None + + def show(self): # nothing to do here pass @@ -90,6 +101,7 @@ def rgb_to_hex(self, rgb): # Zynthian WSLeds class for LED emulation on touchscreen keypad V5 # --------------------------------------------------------------------------- + class zynthian_wsleds_v5touch(zynthian_wsleds_v5): """ Emulation of wsleds for onscreen touch keypad V5 @@ -99,6 +111,9 @@ def start(self): self.wsleds = touchkeypad_button_colors(self) self.light_on_all() + def get_led(self, i): + return self.wsleds[i] + def setup_colors(self): # Predefined colors self.wscolor_off = os.environ.get('ZYNTHIAN_TOUCH_KEYPAD_COLOR_OFF', zynthian_gui_config.color_bg) @@ -125,4 +140,5 @@ def setup_colors(self): str(self.wscolor_orange): "O", str(self.wscolor_yellow): "Y", str(self.wscolor_purple): "P" -} + } + diff --git a/zyngui/zynthian_wsleds_z2.py b/zyngui/zynthian_wsleds_z2.py index c87a06e3c..c68d76bd3 100644 --- a/zyngui/zynthian_wsleds_z2.py +++ b/zyngui/zynthian_wsleds_z2.py @@ -43,11 +43,14 @@ def __init__(self, zyngui): # + arrow => 19, 21, 22, 23 # + BACK/SEL => 18, 20 # + F1-F5 => 8, 9, 10, 11, 12 (display's bottom buttons) + # + CTRL => 9 self.custom_wsleds = [13, 14, 17, 15, 19, - 21, 22, 23, 18, 20, 8, 9, 10, 11, 12] + 21, 22, 23, 18, 20, + 8, 9, 10, 11, 12, + None] def update_wsleds(self): - curscreen = self.zyngui.current_screen + curscreen = self.zyngui.get_current_screen() curscreen_obj = self.zyngui.get_current_screen_obj() # Menu @@ -69,7 +72,7 @@ def update_wsleds(self): if self.zyngui.chain_manager.get_chain(chain_id) is None: self.wsleds[i + 1] = self.wscolor_off else: - if self.zyngui.chain_manager.active_chain_id == chain_id: + if self.zyngui.chain_manager.active_chain.chain_id == chain_id: # => Light active chain if curscreen == "control": self.wsleds[i + 1] = self.wscolor_active @@ -89,41 +92,35 @@ def update_wsleds(self): else: self.wsleds[7] = self.wscolor_default - # Zynpad screen: - if curscreen == "zynpad": + # Zynseq: Launcher / Pattern Editor / Arranger + if curscreen == "launcher": self.wsleds[8] = self.wscolor_active + elif curscreen in ("pattern_editor", "pated_cc", "arranger"): + self.wsleds[8] = self.wscolor_active2 else: self.wsleds[8] = self.wscolor_default - # Pattern Editor/Arranger screen: - if curscreen == "pattern_editor": - self.wsleds[9] = self.wscolor_active - elif curscreen == "arranger": - self.wsleds[9] = self.wscolor_active2 - else: - self.wsleds[9] = self.wscolor_default - - # Control / Preset Screen: + # Control / Preset / Bank Screens: if curscreen in ("control", "audio_player"): - self.wsleds[10] = self.wscolor_active + self.wsleds[9] = self.wscolor_active elif curscreen in ("preset", "bank"): - if self.zyngui.current_processor.get_show_fav_presets(): - self.blink(10, self.wscolor_active2) + if self.zyngui.get_current_processor().get_show_fav_presets(): + self.blink(9, self.wscolor_active2) else: - self.wsleds[10] = self.wscolor_active2 + self.wsleds[9] = self.wscolor_active2 else: - if self.zyngui.alt_mode: - self.wsleds[10] = self.wscolor_alt - else: - self.wsleds[10] = self.wscolor_default + self.wsleds[9] = self.wscolor_default - # ZS3/Snapshot screen: + # ZS3 / Snapshot screens: if curscreen == "zs3": - self.wsleds[11] = self.wscolor_active + self.wsleds[10] = self.wscolor_active elif curscreen == "snapshot": - self.wsleds[11] = self.wscolor_active2 + self.wsleds[10] = self.wscolor_active2 else: - self.wsleds[11] = self.wscolor_default + self.wsleds[10] = self.wscolor_default + + # ???: + self.wsleds[11] = self.wscolor_default # ???: self.wsleds[12] = self.wscolor_default @@ -135,7 +132,7 @@ def update_wsleds(self): self.wsleds[13] = self.wscolor_default if self.zyngui.alt_mode and curscreen != "midi_recorder": - self.zyngui.screens["midi_recorder"].update_wsleds(wsleds) + self.zyngui.screens["midi_recorder"].update_wsleds(self.wsleds) else: # REC Button if self.zyngui.state_manager.audio_recorder.rec_proc: @@ -153,7 +150,7 @@ def update_wsleds(self): # Tempo Screen if curscreen == "tempo": self.wsleds[16] = self.wscolor_active - elif self.zyngui.state_manager.zynseq.libseq.isMetronomeEnabled(): + elif self.zyngui.state_manager.zynseq.libseq.getMetronomeMode() > 0: self.blink(16, self.wscolor_active) else: self.wsleds[16] = self.wscolor_default @@ -171,7 +168,7 @@ def update_wsleds(self): self.wsleds[23] = self.wscolor_yellow # Audio Mixer / ALSA Mixer - if curscreen == "audio_mixer": + if curscreen == "mixer": self.wsleds[24] = self.wscolor_active elif curscreen == "alsa_mixer": self.wsleds[24] = self.wscolor_active2 diff --git a/zynlibs/build.sh b/zynlibs/build.sh new file mode 100755 index 000000000..e28839b66 --- /dev/null +++ b/zynlibs/build.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +scripts=( + "zynsmf/build.sh" + "zynseq/build.sh" + "zynaudioplayer/build.sh" + "zynmixer/build.sh" + "zynclippy/build.sh" +) + +overall_success=0 +failed_scripts=() + +for script in "${scripts[@]}"; do + echo "Running $script..." + "$SCRIPT_PATH/$script" + exit_code=$? + + if [ $exit_code -ne 0 ]; then + echo "❌ $script failed (exit code $exit_code)" + overall_success=1 + failed_scripts+=("$script") + else + echo "✅ $script succeeded" + fi + + echo +done + +echo "==============================" +if [ $overall_success -eq 0 ]; then + echo "🎉 Overall result: SUCCESS — all scripts completed successfully" +else + echo "⚠️ Overall result: FAILURE" + echo "Failed scripts:" + for script in "${failed_scripts[@]}"; do + echo " - $script" + done +fi +echo "==============================" + +exit $overall_success diff --git a/zynlibs/format_docs.py b/zynlibs/format_docs.py new file mode 100755 index 000000000..e1578722c --- /dev/null +++ b/zynlibs/format_docs.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +import sys +import os +import re + +if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + +filename = sys.argv[1] + +if not os.path.isfile(filename): + print(f"'{filename}' is NOT a file.") + sys.exit(1) + +def process_line(line): + # Convert leading tabs to 4 spaces + leading = re.match(r'^(\s*)', line) + if leading: + leading_whitespace = leading.group(1) + # Replace tabs with 4 spaces + converted = leading_whitespace.replace('\t', ' ' * 4) + num_spaces = len(converted) + else: + num_spaces = 0 + + # Replace only the leading whitespace + stripped_line = re.sub(r'^\s*', converted, line) + + # Check pattern + pattern = r"^\s*/\*\*\s+@brief" + match = re.search(pattern, stripped_line) + + return num_spaces, bool(match) + +def split_at_first_whitespace(s): + parts = re.split(r'\s+', s, maxsplit=1) + if len(parts) == 1: + return parts[0], '' + return parts[0], parts[1] + +pattern1 = r"^\s*\**\s*@.*\s" #(param|retval|note|return)" +pattern2 = r"^\s*\*/" +indent = 0 + +with open(filename, "r") as file: + lines = file.readlines() +with open(filename, "w") as file: + for line in lines: + # Get indent to @brief comment + spaces, matched = process_line(line) + if matched: + indent = spaces + a = line.split("@", 1)[1] + keyword, remain = split_at_first_whitespace(a) + spaces = 7 - len(keyword) + file.write(f"{' ' * indent}/** @{keyword}{' ' * spaces}{remain}") + elif re.search(pattern1, line): + a = line.split("@", 1)[1] + keyword, remain = split_at_first_whitespace(a) + spaces = 7 - len(keyword) + file.write(f"{' ' * indent} @{keyword}{' ' * spaces}{remain}") + elif re.search(pattern2, line): + remain = line.strip() + file.write(f"{' ' * indent}{remain}\n") + else: + file.write(line) + diff --git a/zynlibs/zynaudioplayer/player.cpp b/zynlibs/zynaudioplayer/player.cpp index c4f1b8b8e..52f493eb0 100644 --- a/zynlibs/zynaudioplayer/player.cpp +++ b/zynlibs/zynaudioplayer/player.cpp @@ -24,10 +24,9 @@ using namespace RubberBand; using namespace std; // **** Global variables **** -vector g_vPlayers; jack_client_t* g_jack_client; jack_port_t* g_jack_midi_in; -jack_nframes_t g_samplerate = 44100; // Playback samplerate set by jackd +jack_nframes_t g_samplerate = 48000; // Playback samplerate set by jackd uint8_t g_debug = 0; uint8_t g_last_debug = 0; char g_supported_codecs[1024]; @@ -35,6 +34,17 @@ uint8_t g_mutex = 0; uint32_t g_nextIndex = 1; float g_tempo = 2.0; // Tempo in beats per second +struct PlayerVector { + vector players; + ~PlayerVector() { + while (!players.empty()) { + remove_player(players.front()); + } + }; +}; + +static PlayerVector playerVector; + // Declare local functions void set_env_gate(AUDIO_PLAYER* pPlayer, uint8_t gate); void reset_env(AUDIO_PLAYER* pPlayer); @@ -217,7 +227,6 @@ void* file_thread_fn(void* param) { SRC_DATA srcData; size_t nMaxFrames; // Maximum quantity of frames that may be read from file size_t nUnusedFrames = 0; // Quantity of frames in input buffer not used by SRC - SNDFILE* pFile = sf_open(pPlayer->filename.c_str(), SFM_READ, &pPlayer->sf_info); if (!pFile || pPlayer->sf_info.channels < 1) { pPlayer->file_open = FILE_CLOSED; @@ -273,8 +282,8 @@ void* file_thread_fn(void* param) { } SF_INSTRUMENT inst; if (sf_command(pFile, SFC_GET_INSTRUMENT, &inst, sizeof(inst)) == SF_TRUE) { - fprintf(stderr, "File instrument info: gain: %d, detune:%d, velocity: %d-%d, basenote: %d, detune: %d, keyrange: %d-%d\n", inst.gain, - inst.detune, inst.velocity_lo, inst.velocity_hi, inst.basenote, inst.detune, inst.key_lo, inst.key_hi); + fprintf(stderr, "File instrument info: gain: %d, velocity: %d-%d, basenote: %d, detune: %d, keyrange: %d-%d\n", inst.gain, + inst.velocity_lo, inst.velocity_hi, inst.basenote, inst.detune, inst.key_lo, inst.key_hi); pPlayer->gain = pow(10, (float(inst.gain) / 20)); for (int i = 0; i < inst.loop_count; ++i) { fprintf(stderr, "\tLoop %d: mode:%s, start: %d, end:%d, count:%u\n", i, loopModes[inst.loops[i].mode - 800], inst.loops[i].start, @@ -436,11 +445,9 @@ void* file_thread_fn(void* param) { pPlayer->file_read_pos += (nFramesRead = sf_readf_float(pFile, pBufferIn + nUnusedFrames * pPlayer->sf_info.channels, nMaxFrames)); } - getMutex(); if (nFramesRead) { // Got some audio data to process... // Remain in LOADING state to trigger next file read when FIFO has sufficient space - releaseMutex(); DPRINTF("libzynaudioplayer read %u frames into input buffer\n", nFramesRead); if (srcData.src_ratio != 1.0) { @@ -496,12 +503,14 @@ void* file_thread_fn(void* param) { } } else if (pPlayer->loop == 1) { // Short read - looping so fill from loop start point in file + getMutex(); pPlayer->file_read_status = LOOPING; // srcData.end_of_input = 1; releaseMutex(); DPRINTF("libzynaudioplayer read to loop point in input file - setting loading status to looping\n"); } else { // End of file + getMutex(); pPlayer->file_read_status = IDLE; srcData.end_of_input = 1; releaseMutex(); @@ -559,10 +568,14 @@ uint8_t load(AUDIO_PLAYER* pPlayer, const char* filename, cb_fn_t cb_fn) { } void unload(AUDIO_PLAYER* pPlayer) { - if (!pPlayer || !pPlayer->file_thread) + if (!pPlayer) return; stop_playback(pPlayer); + if (pPlayer->file_open == FILE_CLOSED) + return; + getMutex(); pPlayer->file_open = FILE_CLOSED; + releaseMutex(); pPlayer->cue_points.clear(); pthread_join(pPlayer->file_thread, NULL); } @@ -572,11 +585,11 @@ uint8_t save(AUDIO_PLAYER* pPlayer, const char* filename) { return 0; AUDIO_PLAYER* overwrite = NULL; - for (auto it = g_vPlayers.begin(); it != g_vPlayers.end(); ++it) { - if (strcmp((*it)->filename.c_str(), filename) == 0) { + for (const auto player: playerVector.players) { + if (strcmp(player->filename.c_str(), filename) == 0) { // Trying to overwrite an open file - unload(*it); - overwrite = *it; + unload(player); + overwrite = player; break; } } @@ -1175,8 +1188,7 @@ int on_jack_process(jack_nframes_t nFrames, void* arg) { for (jack_nframes_t i = 0; i < nCount; i++) { jack_midi_event_get(&midiEvent, pMidiBuffer, i); uint8_t chan = midiEvent.buffer[0] & 0x0F; - for (auto it = g_vPlayers.begin(); it != g_vPlayers.end(); ++it) { - AUDIO_PLAYER* pPlayer = *it; + for (const auto& pPlayer: playerVector.players) { if (!pPlayer->file_open || pPlayer->midi_chan != chan) continue; uint32_t cue_point_play = pPlayer->cue_points.size(); @@ -1295,8 +1307,7 @@ int on_jack_process(jack_nframes_t nFrames, void* arg) { } } - for (auto it = g_vPlayers.begin(); it != g_vPlayers.end(); ++it) { - AUDIO_PLAYER* pPlayer = *it; + for (const auto& pPlayer: playerVector.players) { if (pPlayer->file_open != FILE_OPEN) continue; @@ -1498,11 +1509,7 @@ void stop_jack() { } static void lib_exit(void) { - fprintf(stderr, "libzynaudioplayer exiting... "); - while (!g_vPlayers.empty()) { - remove_player(g_vPlayers.front()); - } - fprintf(stderr, "done!\n"); + fprintf(stderr, "libzynaudioplayer exiting\n"); } AUDIO_PLAYER* add_player() { @@ -1512,7 +1519,6 @@ AUDIO_PLAYER* add_player() { if (!pPlayer) return nullptr; pPlayer->index = g_nextIndex++; - ; pPlayer->loop_start_src = pPlayer->loop_start * pPlayer->src_ratio; pPlayer->loop_end = pPlayer->input_buffer_size; pPlayer->loop_end_src = pPlayer->loop_end * pPlayer->src_ratio; @@ -1520,7 +1526,7 @@ AUDIO_PLAYER* add_player() { pPlayer->crop_start_src = pPlayer->crop_start * pPlayer->src_ratio; pPlayer->crop_end = pPlayer->input_buffer_size; pPlayer->crop_end_src = pPlayer->crop_end * pPlayer->src_ratio; - g_vPlayers.push_back(pPlayer); + playerVector.players.push_back(pPlayer); set_env_target_ratio_a(pPlayer, 0.3); set_env_target_ratio_dr(pPlayer, 0.0001); @@ -1545,8 +1551,7 @@ AUDIO_PLAYER* add_player() { jack_port_unregister(g_jack_client, pPlayer->jack_out_a); return 0; } - - // fprintf(stderr, "libzynaudioplayer: Created new audio player\n"); + DPRINTF("libaudioplayer player %u registered JACK audio output ports %u & %u\n", pPlayer, pPlayer->jack_out_a, pPlayer->jack_out_b); return pPlayer; } @@ -1555,15 +1560,15 @@ void remove_player(AUDIO_PLAYER* pPlayer) { return; unload(pPlayer); if (jack_port_unregister(g_jack_client, pPlayer->jack_out_a)) { - fprintf(stderr, "libaudioplayer error: cannot unregister audio output port %02dA\n", pPlayer->index); + fprintf(stderr, "libaudioplayer error: player %u (%u) cannot unregister audio output port A %02d\n", pPlayer->index, pPlayer, pPlayer->jack_out_a); } if (jack_port_unregister(g_jack_client, pPlayer->jack_out_b)) { - fprintf(stderr, "libaudioplayer error: cannot unregister audio output port %02dB\n", pPlayer->index); + fprintf(stderr, "libaudioplayer error: player %u (%u) cannot unregister audio output port B %02d\n", pPlayer->index, pPlayer, pPlayer->jack_out_b); } - auto it = find(g_vPlayers.begin(), g_vPlayers.end(), pPlayer); - if (it != g_vPlayers.end()) - g_vPlayers.erase(it); - if (g_vPlayers.size() == 0) + auto it = find(playerVector.players.begin(), playerVector.players.end(), pPlayer); + if (it != playerVector.players.end()) + playerVector.players.erase(it); + if (playerVector.players.size() == 0) stop_jack(); } @@ -1804,8 +1809,8 @@ void set_tempo(float tempo) { if (tempo < 10.0) return; g_tempo = tempo / 60; - for (auto it = g_vPlayers.begin(); it != g_vPlayers.end(); ++it) - updateTempo(*it); + for (const auto& pPlayer: playerVector.players) + updateTempo(pPlayer); } /**** Global functions ***/ @@ -1853,4 +1858,6 @@ void enable_debug(int enable) { int is_debug() { return g_debug; } -unsigned int get_player_count() { return g_vPlayers.size(); } +unsigned int get_player_count() { + return playerVector.players.size(); +} diff --git a/zynlibs/zynaudioplayer/player.h b/zynlibs/zynaudioplayer/player.h index c18918527..f7be33c38 100644 --- a/zynlibs/zynaudioplayer/player.h +++ b/zynlibs/zynaudioplayer/player.h @@ -75,30 +75,30 @@ const char* get_codec(AUDIO_PLAYER* pPlayer); AUDIO_PLAYER* add_player(); /** @brief Remove player from library - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() */ void remove_player(AUDIO_PLAYER* pPlayer); /** @brief Set the MIDI base note - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param base_note MIDI note that will trigger playback at normal speed */ void set_base_note(AUDIO_PLAYER* pPlayer, uint8_t base_note); /** @brief Get the MIDI base note - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval uint8_t MIDI note that will trigger playback at normal speed */ uint8_t get_base_note(AUDIO_PLAYER* pPlayer); /** @brief Set player MIDI channel - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param midi_chan MIDI channel (0..15 or other value to disable MIDI listen) */ void set_midi_chan(AUDIO_PLAYER* pPlayer, uint8_t midi_chan); /** @brief Get player index - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param index ID of the player */ int get_index(AUDIO_PLAYER* pPlayer); @@ -109,7 +109,7 @@ int get_index(AUDIO_PLAYER* pPlayer); const char* get_jack_client_name(); /** @brief Open audio file - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param filename Full path and name of file to load * @param cb_fn Pointer to callback function with template void(float) * @retval uint8_t True on success @@ -117,7 +117,7 @@ const char* get_jack_client_name(); uint8_t load(AUDIO_PLAYER* pPlayer, const char* filename, cb_fn_t cb_fn); /** @brief Save audio file - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param filename Full path and name of file to create or overwrite * @retval uint8_t True on success * @note Crops file by crop markers and saves cue points as metadata @@ -125,96 +125,96 @@ uint8_t load(AUDIO_PLAYER* pPlayer, const char* filename, cb_fn_t cb_fn); uint8_t save(AUDIO_PLAYER* pPlayer, const char* filename); /** @brief Close audio file clearing all data - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() */ void unload(AUDIO_PLAYER* pPlayer); /** @brief Get filename of currently loaded file - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval const char* Filename or emtpy string if no file loaded */ const char* get_filename(AUDIO_PLAYER* pPlayer); /** @brief Get duration of audio - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval float Duration in seconds */ float get_duration(AUDIO_PLAYER* pPlayer); /** @brief Set playhead position - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param time Time in seconds since start of audio */ void set_position(AUDIO_PLAYER* pPlayer, float time); /** @brief Get playhead position - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval float Time in seconds since start of audio */ float get_position(AUDIO_PLAYER* pPlayer); /** @brief Set loop mode - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param nLoop 1 to loop at end of audio, 2 to play to end (ignore MIDI note-off) */ void enable_loop(AUDIO_PLAYER* pPlayer, uint8_t nLoop); /* @brief Get loop mode - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval uint8_t 1 if looping, 0 if one-shot */ uint8_t is_loop(AUDIO_PLAYER* pPlayer); /** @brief Set start of loop - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param time Start of loop in seconds since start of file */ void set_loop_start_time(AUDIO_PLAYER* pPlayer, float time); /** @brief Get start of loop - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval float Start of loop in seconds since start of file */ float get_loop_start_time(AUDIO_PLAYER* pPlayer); /** @brief Set end of loop - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param time End of loop in seconds since end of file */ void set_loop_end_time(AUDIO_PLAYER* pPlayer, float time); /** @brief Get end of loop - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval float End of loop in seconds since end of file */ float get_loop_end_time(AUDIO_PLAYER* pPlayer); /** @brief Set start of audio (crop) - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param time Start of crop in seconds since start of file */ void set_crop_start_time(AUDIO_PLAYER* pPlayer, float time); /** @brief Get start of audio (crop) - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval float Start of crop in seconds since start of file */ float get_crop_start_time(AUDIO_PLAYER* pPlayer); /** @brief Set end audio (crop) - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param time End of crop in seconds since end of file */ void set_crop_end_time(AUDIO_PLAYER* pPlayer, float time); /** @brief Get end of audio (crop) - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval float End of crop in seconds since end of file */ float get_crop_end_time(AUDIO_PLAYER* pPlayer); /** @brief Add a cue marker - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param position Position within file (in seconds) to add marker * @param name Cue point name * @retval int32_t Index of marker or -1 on failure @@ -222,7 +222,7 @@ float get_crop_end_time(AUDIO_PLAYER* pPlayer); int32_t add_cue_point(AUDIO_PLAYER* pPlayer, float position, const char* name = nullptr); /** @brief Remove a cue marker - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param position Position within file (in secondes) of marker to remove * @retval int32_t Index of removed maker or -1 on failure * @note The closest marker within +/-0.5s will be removed @@ -230,20 +230,20 @@ int32_t add_cue_point(AUDIO_PLAYER* pPlayer, float position, const char* name = int32_t remove_cue_point(AUDIO_PLAYER* pPlayer, float position); /** @brief Get quantity of cue points - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval uint32_t Quantity of cue points */ uint32_t get_cue_point_count(AUDIO_PLAYER* pPlayer); /** @brief Get a cue point's position - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param index Index of cue point * @retval float Position (in seconds) of cue point or -1.0 if not found */ float get_cue_point_position(AUDIO_PLAYER* pPlayer, uint32_t index); /** @brief Set a cue point's position - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param index Index of cue point * @param position Position (in seconds) of cue point * @retval bool True on success @@ -251,14 +251,14 @@ float get_cue_point_position(AUDIO_PLAYER* pPlayer, uint32_t index); bool set_cue_point_position(AUDIO_PLAYER* pPlayer, uint32_t index, float position); /** @brief Get a cue point's name - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param index Index of cue point * @retval char* Name of cue point or "" if not found */ const char* get_cue_point_name(AUDIO_PLAYER* pPlayer, uint32_t index); /** @brief Set a cue point's name - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param index Index of cue point * @param name Name of cue point (as c-string) - max 255 characters * @retval bool True on success @@ -266,53 +266,53 @@ const char* get_cue_point_name(AUDIO_PLAYER* pPlayer, uint32_t index); bool set_cue_point_name(AUDIO_PLAYER* pPlayer, uint32_t index, const char* name); /** @brief Clear all cue points - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() */ void clear_cue_points(AUDIO_PLAYER* pPlayer); /** @brief Start playback - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() */ void start_playback(AUDIO_PLAYER* pPlayer); /** @brief Stop playback - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() */ void stop_playback(AUDIO_PLAYER* pPlayer); /** @brief Get play state - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval uint8_t Play state [STOPPED|STARTING|PLAYING|STOPPING] */ uint8_t get_playback_state(AUDIO_PLAYER* pPlayer); /** @brief Get samplerate of currently loaded file - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval int Samplerate in samples per seconds */ int get_samplerate(AUDIO_PLAYER* pPlayer); /** @brief Get quantity of channels in currently loaded file - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval int Quantity of channels, e.g. 2 for stereo */ int get_channels(AUDIO_PLAYER* pPlayer); /** @brief Get quantity of frames (samples) in currently loaded file - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval int Quantity of frames */ int get_frames(AUDIO_PLAYER* pPlayer); /** @brief Get format of currently loaded file - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval int Bitwise OR of major and minor format type and optional endianness value * @see sndfile.h for supported formats */ int get_format(AUDIO_PLAYER* pPlayer); /** @brief Set samplerate converter quality - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param quality Samplerate conversion quality [SRC_SINC_BEST_QUALITY | SRC_SINC_MEDIUM_QUALITY | SRC_SINC_FASTEST | SRC_ZERO_ORDER_HOLD | SRC_LINEAR] * @retval uint8_t True on success, i.e. the quality parameter is valid * @note Quality will apply to subsequently opened files, not currently open file @@ -320,124 +320,124 @@ int get_format(AUDIO_PLAYER* pPlayer); uint8_t set_src_quality(AUDIO_PLAYER* pPlayer, unsigned int quality); /** @brief Get samplerate converter quality - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval unsigned int Samplerate conversion quality [SRC_SINC_BEST_QUALITY | SRC_SINC_MEDIUM_QUALITY | SRC_SINC_FASTEST | SRC_ZERO_ORDER_HOLD | SRC_LINEAR] * @note Quality applied to subsequently opened files, not necessarily currently open file */ unsigned int get_src_quality(AUDIO_PLAYER* pPlayer); /** @brief Set gain - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param gain Gain factor (0.01..2.0) */ void set_gain(AUDIO_PLAYER* pPlayer, float gain); /** @brief Get gain (volume) - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval float Gain */ float get_gain(AUDIO_PLAYER* pPlayer); /** @brief Set track to playback to left output - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param track Index of track to play to left output or -1 for mix of all odd tracks */ void set_track_a(AUDIO_PLAYER* pPlayer, int track); /** @brief Set track to playback to right output - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param track Index of track to play to right output or -1 for mix of all even tracks */ void set_track_b(AUDIO_PLAYER* pPlayer, int track); /** @brief Get track to playback to left output - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval int Index of track to play or -1 for mix of all tracks */ int get_track_a(AUDIO_PLAYER* pPlayer); /** @brief Get track to playback to right output - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval int Index of track to play or -1 for mix of all tracks */ int get_track_b(AUDIO_PLAYER* pPlayer); /** @brief Set pitchbend range - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param range Range in semitones */ void set_pitchbend_range(AUDIO_PLAYER* pPlayer, uint8_t range); /** @brief Get pitchbend range - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval uint8_t Range in semitones */ uint8_t get_pitchbend_range(AUDIO_PLAYER* pPlayer); /** @brief Set base speed - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param factor Speed factor (0.25..4.0) */ void set_speed(AUDIO_PLAYER* pPlayer, float factor); /** @brief Get base speed - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval float Speed factor */ float get_speed(AUDIO_PLAYER* pPlayer); /** @brief Set base pitch - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param factor Pitch factor (0.25..4.0) */ void set_pitch(AUDIO_PLAYER* pPlayer, float factor); /** @brief Get base pitch - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval float Pitch factor */ float get_pitch(AUDIO_PLAYER* pPlayer); /** @brief Set varispeed - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param ratio Ratio of speed:pitch (1.0 for no varispeed, -1.0 for reverse, 0.0 for stopped) */ void set_varispeed(AUDIO_PLAYER* pPlayer, float ratio); /** @brief Get varispeed - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval float Ratio of speed:pitch (1.0 for no varispeed) */ float get_varispeed(AUDIO_PLAYER* pPlayer); /** @brief Set size of file read buffers - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param size Size of buffers in frames * @note Cannot change size whilsts file is open */ void set_buffer_size(AUDIO_PLAYER* pPlayer, unsigned int size); /** @brief Get size of file read buffers - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval unsigned int Size of buffers in frames */ unsigned int get_buffer_size(AUDIO_PLAYER* pPlayer); /** @brief Set factor by which ring buffer is larger than file read buffers - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param count Quantity of buffers * @note Cannot change count whilst file is open */ void set_buffer_count(AUDIO_PLAYER* pPlayer, unsigned int count); /** @brief Get factor by which ring buffer is larger than file read buffers - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval unsigned int Quantity of buffers */ unsigned int get_buffer_count(AUDIO_PLAYER* pPlayer); /** @brief Set difference in postion that will trigger notificaton - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param time Time difference in seconds */ void set_pos_notify_delta(AUDIO_PLAYER* pPlayer, float time); @@ -445,97 +445,97 @@ void set_pos_notify_delta(AUDIO_PLAYER* pPlayer, float time); /**** Envelope functions ****/ /** @brief Set envelope attack rate - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param rate Attack rate */ void set_env_attack(AUDIO_PLAYER* pPlayer, float rate); /** @brief Get envelope attack rate - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval Attack rate */ float get_env_attack(AUDIO_PLAYER* pPlayer); /** @brief Set envelope hold time - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param hold Time in seconds to hold between attack and decay phases */ void set_env_hold(AUDIO_PLAYER* pPlayer, float hold); /** @brief Get envelope hold time - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval Time in seconds between attack and decay phases */ float get_env_hold(AUDIO_PLAYER* pPlayer); /** @brief Set envelope decay rate - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param rate Decay rate */ void set_env_decay(AUDIO_PLAYER* pPlayer, float rate); /** @brief Get envelope decay rate - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval Decay rate */ float get_env_decay(AUDIO_PLAYER* pPlayer); /** @brief Set envelope release rate - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param rate Release rate */ void set_env_release(AUDIO_PLAYER* pPlayer, float rate); /** @brief Get envelope release rate - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval Release rate */ float get_env_release(AUDIO_PLAYER* pPlayer); /** @brief Set envelope sustain level - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param level Sustain level */ void set_env_sustain(AUDIO_PLAYER* pPlayer, float level); /** @brief Get envelope sustain level - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval Sustain level */ float get_env_sustain(AUDIO_PLAYER* pPlayer); /** @brief Set envelope attack target ratio (curve) - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param ratio Target ratio */ void set_env_target_ratio_a(AUDIO_PLAYER* pPlayer, float ratio); /** @brief Get envelope attack target ratio (curve) - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval Target ratio */ float get_env_target_ratio_a(AUDIO_PLAYER* pPlayer); /** @brief Set envelope decay / release target ratio (curve) - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param ratio Target ratio */ void set_env_target_ratio_dr(AUDIO_PLAYER* pPlayer, float ratio); /** @brief Get envelope decay / release target ratio (curve) - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval Target ratio */ float get_env_target_ratio_dr(AUDIO_PLAYER* pPlayer); /** @brief Set the quantity of beats in a loop - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @param beats Quantity of beats or 0 for no loop behaviour */ void set_beats(AUDIO_PLAYER* pPlayer, uint8_t beats); /** @brief Get the quantity of beats in a loop - * @param player_handle Handle of player provided by init_player() + * @param player_handle Handle of player provided by add_player() * @retval uint8_t Quantity of beats */ uint8_t get_beats(AUDIO_PLAYER* pPlayer); diff --git a/zynlibs/zynclippy/CMakeLists.txt b/zynlibs/zynclippy/CMakeLists.txt new file mode 100644 index 000000000..993ede6e2 --- /dev/null +++ b/zynlibs/zynclippy/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.0) +project(zynclippy) + +include(CheckIncludeFiles) +include(CheckLibraryExists) + +link_directories(/usr/local/lib) + +add_library(zynclippy SHARED clippy.c) +add_definitions(-Werror) +target_link_libraries(zynclippy jack sndfile samplerate rubberband) + +install(TARGETS zynclippy LIBRARY DESTINATION lib) diff --git a/zynlibs/zynclippy/build.sh b/zynlibs/zynclippy/build.sh new file mode 100755 index 000000000..a7d077ae5 --- /dev/null +++ b/zynlibs/zynclippy/build.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +pushd $DIR + if [ ! -d build ]; then + mkdir build + fi + pushd build + cmake -D CMAKE_CXX_FLAGS="-Wno-psabi" -D ENABLE_OSC=0 .. + make + success=$? + popd +popd +exit $success diff --git a/zynlibs/zynclippy/clippy.c b/zynlibs/zynclippy/clippy.c new file mode 100644 index 000000000..e8b0302ea --- /dev/null +++ b/zynlibs/zynclippy/clippy.c @@ -0,0 +1,825 @@ +/* + * ****************************************************************** + * ZYNTHIAN PROJECT: zynclippy Library + * + * Library providing sample clip launcher as a Jack connected device + * + * Copyright (C) 2025 Brian Walton + * + * ****************************************************************** + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * For a full copy of the GNU General Public License see the LICENSE.txt file. + * + * ****************************************************************** + */ + +#include "clippy.h" +#include // provides usleep +#include // provides memset, memcpy, strcpy +#include // provides jack API +#include // provides jack midi port API +#include //provides jack ring buffer +#include // provides sound file manipulation +#include // provides samplerate convertor +#include // provides time stretch +#include // provides threading +#include // provides pow for dB calcs + +typedef struct { + uint8_t state; // Clip state + uint32_t frames; // Quantity of frames in loaded clip + uint8_t channels; // Quantity of channels in clip + float gain; // Gain factor + char path[256]; // Loaded file path and filename + float preload_buffer_a[PRELOAD_FRAMES]; // Buffer holds start of sample + float preload_buffer_b[PRELOAD_FRAMES]; // Buffer holds start of sample +} Clip; + +typedef struct { + uint8_t state; // Play state + uint32_t play_pos; // Position of playhead in frames + jack_ringbuffer_t* ringbuffer_a; // Ring buffers to pass left data between threads (L/R in each buffer) + jack_ringbuffer_t* ringbuffer_b; // Ring buffers to pass right data between threads (L/R in each buffer) + jack_port_t* jack_out_a; // Left jack output port + jack_port_t* jack_out_b; // Right jack output port + SNDFILE* sndfile; // Pointer to an open sndfile used to read current clip data + Clip* current_clip; // Pointer to the currently selected / playing clip + Clip* clips[MAX_CLIPS]; // Array of pointers to clip objects +} Player; + +typedef union { + uint32_t u32; + struct { + uint8_t value2; + uint8_t value1; + uint8_t command; + uint8_t unused; + }; +} MidiMsg; + +// Global variables +jack_nframes_t samplerate = 48000; +jack_nframes_t buffersize = 1024; +static jack_port_t* midi_input_port; +static jack_client_t* jack_client; +static volatile uint8_t running = 1; +static volatile uint8_t mutex = 0; +pthread_t file_thread; // ID of file loader thread + +Player* players[16]; // Up to 16 players, 1 per MIDI channel + +static void inline getMutex() { + while (mutex) + usleep(100); + mutex = 1; +} + +static void inline releaseMutex() { + mutex = 0; +} + +// Background file and buffer management +void* file_thread_fn(void* param) { + Player* player; + SNDFILE* sndfile; // Used to refresh preload buffers + struct SF_INFO info; + float a, b; + size_t write_space_a, write_space_b, write_space; + + while (running) { + for (uint8_t channel = 0; channel < 16; ++channel) { + player = players[channel]; + if (!player || !player->current_clip) + continue; + if (player->state == STATE_STARTING) { + //!@todo Check if same file already open + sf_close(player->sndfile); + player->sndfile = sf_open(player->current_clip->path, SFM_READ, &info); + if (player->sndfile) { + sf_seek(player->sndfile, PRELOAD_FRAMES, SEEK_SET); + getMutex(); + jack_ringbuffer_reset(player->ringbuffer_a); + jack_ringbuffer_reset(player->ringbuffer_b); + player->state = STATE_PLAYING; + } else { + getMutex(); + player->state = STATE_IDLE; + } + releaseMutex(); + } + if (player->state == STATE_PLAYING) { + // Load data from file to ring buffer + size_t write_space_a = jack_ringbuffer_write_space(player->ringbuffer_a); + size_t write_space_b = jack_ringbuffer_write_space(player->ringbuffer_b); + write_space = (write_space_a < write_space_b) ? write_space_a : write_space_b; + if (write_space > 127) { + int free_frames = write_space / sizeof(float); + float buffer[free_frames * player->current_clip->channels]; + int frames = sf_readf_float(player->sndfile, buffer, free_frames); + int stereo = player->current_clip->channels > 1 ? 1 : 0; + for (uint32_t i = 0; i < free_frames; ++i) { + if (i < frames) { + a = buffer[i * player->current_clip->channels]; + b = buffer[i * player->current_clip->channels + stereo]; + } else { + a = b = 0.0f; // Silence remaining frames + } + jack_ringbuffer_write(player->ringbuffer_a, (const char*)(&a), sizeof(float)); + jack_ringbuffer_write(player->ringbuffer_b, (const char*)(&b), sizeof(float)); + } + } + } + } + usleep(100); + } + pthread_exit(NULL); +} + +static int process(jack_nframes_t frames, __attribute__((unused)) void* arg) { + static char buffer[1048]; + static Player* player; + float* out_buff_a[16]; + float* out_buff_b[16]; + + while (mutex) + usleep(10); + mutex = 1; + + // Populate player audio output buffers from preload/ring buffers + for (uint8_t channel = 0; channel < 16; ++channel) { + player = players[channel]; + if (player) { + out_buff_a[channel] = jack_port_get_buffer(player->jack_out_a, frames); + out_buff_b[channel] = jack_port_get_buffer(player->jack_out_b, frames); + memset(out_buff_a[channel], 0, frames * sizeof(float)); + memset(out_buff_b[channel], 0, frames * sizeof(float)); + if (player->state == STATE_STARTING || player->state == STATE_PLAYING) { + if (!player->current_clip) + continue; + int frame_count = 0; + if (player->play_pos < PRELOAD_FRAMES) { + // Play remaining prebuffer + frame_count = PRELOAD_FRAMES - player->play_pos; + if (frame_count > frames) + frame_count = frames; + size_t count = frame_count * sizeof(float); + memcpy(out_buff_a[channel], player->current_clip->preload_buffer_a + player->play_pos, count); + memcpy(out_buff_b[channel], player->current_clip->preload_buffer_b + player->play_pos, count); + player->play_pos += frame_count; + } + + // Stream from ringbuffer + if (frame_count < frames) { + size_t count = (frames - frame_count) * sizeof(float); + count = jack_ringbuffer_read(player->ringbuffer_a, (char*)(out_buff_a[channel]), count); + if (count % sizeof(float)) + fprintf(stderr, "Error reading ringbuffer_a: %u\n", count); + count = jack_ringbuffer_read(player->ringbuffer_b, (char*)(out_buff_b[channel]), count); + if (count % sizeof(float)) + fprintf(stderr, "Error reading ringbuffer_b: %u\n", count); + player->play_pos += count / sizeof(float); + } + if (player->play_pos >= player->current_clip->frames) { + // Reached end of clip + player->state = STATE_STOPPING; + } + } + } + } + + void* midi_buffer = jack_port_get_buffer(midi_input_port, frames); + jack_nframes_t numMidiEvents = jack_midi_get_event_count(midi_buffer); + jack_midi_event_t event; + + // Received MIDI messages + for (uint32_t i = 0; i < numMidiEvents; ++i) { + if (jack_midi_event_get(&event, midi_buffer, i) != 0) + continue; + + if (event.size == 0) + continue; + + switch (event.buffer[0] & 0xf0) { + case MIDI_NOTE_ON: + if (event.buffer[2] != 0) { + // Note on triggers playback from preload buffer + uint8_t channel = event.buffer[0] & 0x0f; + player = players[channel]; + if (!player) + break; + if (event.buffer[1] == 0) { + // Note 0 stops playback + if (player->state == STATE_PLAYING || player->state == STATE_STARTING) { + player->state = STATE_STOPPING; + } + } else { + // Start playing clip from preload buffer + player->current_clip = player->clips[event.buffer[1] - 1]; + if (!player->current_clip || player->current_clip->state != STATE_READY) + break; + // Start audio at frame offset of MIDI event + size_t start = event.time * sizeof(float); + uint32_t frame_count = frames - event.time; + size_t count = frame_count * sizeof(float); + memcpy(out_buff_a[channel] + start, player->current_clip->preload_buffer_a, count); + memcpy(out_buff_b[channel] + start, player->current_clip->preload_buffer_b, count); + player->play_pos = frame_count; + //!@todo Crossfade + player->state = STATE_STARTING; + } + break; + } + [[fallthrough]]; + case MIDI_NOTE_OFF: + // Not handling note-off + break; + case MIDI_CC: + //setGain(event.buffer[0] & 0x0f, event.buffer[1], (float)(event.buffer[2]) / 64); + break; + } + } + + // Adjust volume + float dGain; + for (uint8_t channel = 0; channel < 16; ++channel) { + player = players[channel]; + if (!player || !player->current_clip) + continue; + if (player->state == STATE_STOPPING) { + dGain = player->current_clip->gain / frames; // Soft fade + player->state = STATE_READY; + player->play_pos = 0; + } else if (player->state == STATE_STARTING || player->state == STATE_PLAYING) { + dGain = 0.0f; + } else { + continue; + } + for (uint32_t i = 0; i < frames; ++i) { + out_buff_a[channel][i] *= (player->current_clip->gain - i * dGain); + out_buff_b[channel][i] *= (player->current_clip->gain - i * dGain); + } + } + + mutex = 0; + return 0; +} + +void reset() { + getMutex(); + for (uint8_t channel = 0; channel < 16; ++channel) { + Player* player = players[channel]; + if (!player) + continue; + player->state=STATE_LOAD; + for (uint32_t id = 0; id < MAX_CLIPS; ++id) { + Clip* clip = player->clips[id]; + if (clip) + loadClip(channel, id, clip->path); + } + } + releaseMutex(); +} + +static int onBufferSize(jack_nframes_t frames, __attribute__((unused)) void* arg) +{ + buffersize = frames; + reset(); + return 0; +} + +static int onSamplerate(jack_nframes_t frames, __attribute__((unused)) void* arg) +{ + samplerate = frames; + reset(); + return 0; +} + +void end() { + running = 0; + void* status; + pthread_join(file_thread, &status); + + for (uint8_t i = 0; i < 16; ++i) + removePlayer(i); + if (jack_client) + jack_client_close(jack_client); + jack_client = NULL; +} + +/** @brief Initialise the library + @param jackname Requested jack client name + @retval int Error code +*/ +int init() { + int error = ERROR_SUCCESS; + if (jack_client) + return ERROR_EXISTS; + // Register the cleanup function to be called when library exits + atexit(end); + + // Initialise players + for (uint8_t i = 0; i < 16; ++i) + players[i] = NULL; + + // Configure and start event thread + running = 1; + pthread_attr_t attr; + pthread_attr_init(&attr); + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE); + if (pthread_create(&file_thread, &attr, file_thread_fn, NULL)) { + fprintf(stderr, "Clippy error: failed to create file reader thread\n"); + return ERROR_CREATE; + } + + // Create jack client + jack_status_t status; + jack_client = jack_client_open("clippy", JackNullOption, &status); + if (jack_client == NULL) { + fprintf(stderr, "Could not open JACK client\n"); + end(); + return ERROR_CREATE; + } + + midi_input_port = jack_port_register(jack_client, "in", JACK_DEFAULT_MIDI_TYPE, JackPortIsInput, 0); + if (midi_input_port == NULL) { + fprintf(stderr, "Could not open MIDI input port\n"); + end(); + return ERROR_PORT; + } + + jack_set_sample_rate_callback(jack_client, onSamplerate, NULL); + jack_set_buffer_size_callback(jack_client, onBufferSize, NULL); + jack_set_process_callback(jack_client, process, NULL); + + if (jack_activate(jack_client) != 0) { + fprintf(stderr, "Could not activate client\n"); + end(); + return ERROR_ACTIVATE; + } + return ERROR_SUCCESS; +} + +/** @brief Get the jack client name + @retval const char* Jack name +*/ +const char* getJackname() { + return jack_get_client_name(jack_client); +} + +uint8_t addPlayer(uint8_t channel) { + if (channel >= 16) { + for (channel = 0; channel < 16; ++channel) { + if (players[channel] == NULL) + break; + } + } + if (channel > 16) + return 255; + Player* player = malloc(sizeof(Player)); + if (!player) + return ERROR_CREATE; + + memset(player, 0, sizeof(Player)); + char name[16]; + sprintf(name, "out_%02ua", channel + 1); + player->jack_out_a = jack_port_register(jack_client, name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0); + sprintf(name, "out_%02ub", channel + 1); + player->jack_out_b = jack_port_register(jack_client, name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0); + if (player->jack_out_a == NULL || player->jack_out_b == NULL) + fprintf(stderr, "Clippy error: failed to create jack output ports\n"); + for (uint32_t id = 0; id < MAX_CLIPS; ++id) + player->clips[id] = NULL; + player->ringbuffer_a = jack_ringbuffer_create(RINGBUFFER * sizeof(float)); + player->ringbuffer_b = jack_ringbuffer_create(RINGBUFFER * sizeof(float)); + jack_ringbuffer_mlock(player->ringbuffer_a); + jack_ringbuffer_mlock(player->ringbuffer_b); + getMutex(); + players[channel] = player; + releaseMutex(); + return channel; +} + +uint32_t removePlayer(uint8_t channel) { + if(channel >= 16) + return ERROR_RANGE; + Player* player = players[channel]; + if(player == NULL) + return ERROR_CREATE; + getMutex(); + players[channel] = NULL; + releaseMutex(); + + for (uint32_t id = 0; id < MAX_CLIPS; ++id) + free(player->clips[id]); + jack_ringbuffer_free(player->ringbuffer_a); + jack_ringbuffer_free(player->ringbuffer_b); + + jack_port_unregister(jack_client, player->jack_out_a); + jack_port_unregister(jack_client, player->jack_out_b); + free(player); + return ERROR_SUCCESS; +} + +uint8_t swapClip(uint8_t channel, uint8_t clip1, uint8_t clip2) { + if (clip1 >= MAX_CLIPS || clip2 >= MAX_CLIPS || channel > 15) + return ERROR_RANGE; + Player* pPlayer = players[channel]; + if (!pPlayer) + return ERROR_RANGE; + Clip* pClip1 = pPlayer->clips[clip1]; + Clip* pClip2 = pPlayer->clips[clip2]; + getMutex(); //!@todo Check this won't leave stuck notes + pPlayer->clips[clip1] = pClip2; + pPlayer->clips[clip2] = pClip1; + releaseMutex(); + return ERROR_SUCCESS; +} + +uint8_t insertClip(uint8_t channel, uint8_t clip) { + if (channel > 15 || clip >= MAX_CLIPS) + return ERROR_RANGE; + Player* pPlayer = players[channel]; + if (!pPlayer) + return ERROR_RANGE; + if (pPlayer->clips[MAX_CLIPS - 1]) + return ERROR_EXISTS; + for (uint8_t i = MAX_CLIPS - 1; i > clip ; --i) { + pPlayer->clips[i] = pPlayer->clips[i - 1]; + } + pPlayer->clips[clip] = NULL; + return ERROR_SUCCESS; +} + +uint8_t removeClip(uint8_t channel, uint8_t clip) { + if (channel > 15 || clip >= MAX_CLIPS) + return ERROR_RANGE; + Player* pPlayer = players[channel]; + if (!pPlayer) + return ERROR_RANGE; + unloadClip(channel, clip + 1); + for (uint8_t i = clip; i < MAX_CLIPS - 1; ++i) { + pPlayer->clips[i] = pPlayer->clips[i + 1]; + } + pPlayer->clips[MAX_CLIPS - 1] = NULL; + return ERROR_SUCCESS; +} + +uint8_t getFreeClip(uint8_t channel) { + if(channel >= 16) + return 0; + Player* player = players[channel]; + if (!player) + return 0; + for (uint8_t id = 0; id < MAX_CLIPS; ++id) { + if (player->clips[id] == NULL) + return id + 1; + } + return 0; +} + +uint8_t loadClip(uint8_t channel, uint8_t note, const char* path) { + if(channel >= 16 || note >= MAX_CLIPS) + return 0; + Player* player = players[channel]; + if (!player) + return 0; + + if (note == 0) { + // Find next available note + for (note = 0; note < 128; ++ note) { + if (!player->clips[note]) + break; + } + if (++note > 127) + return 0; + } + uint8_t id = note - 1; + + // Load data from file to preload cache + struct SF_INFO info; + SNDFILE* sndfile = sf_open(path, SFM_READ, &info); + Clip* clip = NULL; + + if (sndfile && info.channels && info.frames) { + if (info.samplerate != samplerate) { + fprintf(stderr, "File samplerate: %u is not system samplerate %u\n", info.samplerate, samplerate); + sf_close(sndfile); + return 0; + } + // Create a new clip instance and configure + clip = malloc(sizeof(Clip)); + if (!clip) { + fprintf(stderr, "Clippy error: failed to create new clip object\n"); + sf_close(sndfile); + return 0; + } + + memset(clip, 0, sizeof(Clip)); + clip->gain = 1.0f; + strcpy(clip->path, path); + clip->channels = info.channels; + clip->frames = info.frames; + float buffer[PRELOAD_FRAMES * info.channels * sizeof(float)]; + sf_count_t frames = sf_readf_float(sndfile, buffer, PRELOAD_FRAMES); + int stereo = info.channels > 1 ? 1 : 0; + for (uint32_t i = 0; i < PRELOAD_FRAMES; ++i) { + if (i < frames) { + clip->preload_buffer_a[i] = buffer[i * info.channels]; + clip->preload_buffer_b[i] = buffer[i * info.channels + stereo]; + } else { + // Silence remaining frames + clip->preload_buffer_a[i] = 0.0f; + clip->preload_buffer_b[i] = 0.0f; + } + } + jack_ringbuffer_reset(player->ringbuffer_a); + jack_ringbuffer_reset(player->ringbuffer_b); + clip->state = STATE_READY; + sf_close(sndfile); + } else { + sf_close(sndfile); + return 0; + } + unloadClip(channel, note); + player->clips[id] = clip; + //fprintf(stderr, "clippy loadClip(channel=%u, note=%u, path=%s) id=%u\n", channel, note, path, id); + return note; +} + +uint8_t unloadClip(uint8_t channel, uint8_t note) { + if(channel >= 16) + return ERROR_RANGE; + Player* player = players[channel]; + if(player == NULL) + return ERROR_RANGE; + uint8_t id = note - 1; + if(id >= MAX_CLIPS) + return ERROR_RANGE; + Clip* clip = player->clips[id]; + if(clip == NULL) + return ERROR_RANGE; + + if (player->current_clip == player->clips[id]) { + getMutex(); + player->state = STATE_IDLE; + releaseMutex(); + } + player->clips[id] = NULL; + free(clip); + return ERROR_SUCCESS; +} + +float toDb(float val) { + return 20.0 * log10(val); +} + +float fromDb(float val) { + return pow(10.0, val / 20.0); +} + +uint8_t setGain(uint8_t channel, uint8_t id, float gain) { + if (channel > 15) + return ERROR_RANGE; + Player* player = players[channel]; + if (!player) + return ERROR_RANGE; + if (id >= MAX_CLIPS) + return ERROR_RANGE; + Clip* clip = player->clips[id]; + if (!clip) + return ERROR_RANGE; + clip->gain = fromDb(gain); + return ERROR_SUCCESS; +} + +float getGain(uint8_t channel, uint8_t id) { + if (channel > 15) + return 0.0f; + Player* player = players[channel]; + if (!player) + return 0.0f; + if (id >= MAX_CLIPS) + return 0.0f; + Clip* clip = player->clips[id]; + if (!clip) + return 0.0f; + return toDb(clip->gain); +} + +uint32_t getFileSamplerate(const char* path) { + SF_INFO sf_info; + memset(&sf_info, 0, sizeof(sf_info)); + SNDFILE* sndfile = sf_open(path, SFM_READ, &sf_info); + if (!sndfile) + return 0; + sf_close(sndfile); + return sf_info.samplerate; +} + +uint32_t getFileFrames(const char* path) { + SF_INFO sf_info; + memset(&sf_info, 0, sizeof(sf_info)); + SNDFILE* sndfile = sf_open(path, SFM_READ, &sf_info); + if (!sndfile) + return 0; + sf_close(sndfile); + return sf_info.frames; +} + +int copyFile(const char* src_path, const char* dst_path, uint8_t quality, float ratio, uint32_t start, uint32_t end) { + uint8_t error = 0; + if (ratio < 0.001 || ratio > 1000) + ratio = 1.0; // Default to no stretch if excessive stretch requested. + if (quality > 4) + return ERROR_RANGE; + + // Read source file into interleaved float buffer data_in + SF_INFO sf_info; + memset(&sf_info, 0, sizeof(sf_info)); + SNDFILE* sndfile = sf_open(src_path, SFM_READ, &sf_info); + if (!sndfile || sf_info.samplerate < 10 || sf_info.channels < 1 || sf_info.frames < 10) + return ERROR_OPEN; + int channels = sf_info.channels; + sf_count_t frames = sf_info.frames; + int dur = end - start; + if (dur < frames && dur > 0) + frames = dur; + uint8_t src = samplerate != sf_info.samplerate; + uint8_t stretch = ratio != 0.0f; + + size_t size = frames * sf_info.channels * sizeof(float); + if (size == 0) { + sf_close(sndfile); + return ERROR_OPEN; + } + float* data_in = (float*)malloc(size); + if (!data_in) { + sf_close(sndfile); + return ERROR_CREATE; + } + sf_seek(sndfile, start, SEEK_SET); + sf_count_t count = sf_readf_float(sndfile, data_in, frames); + sf_close(sndfile); + + if (count != frames) { + free(data_in); + return ERROR_OPEN; + } + + // Deinterleave source audio into array of buffers data_deinterleaved[] + float* data_deinterleaved[channels]; + for (int ch = 0; ch < channels; ch++) { + data_deinterleaved[ch] = malloc(frames * sizeof(float)); + for (sf_count_t i = 0; i < frames; i++) { + data_deinterleaved[ch][i] = data_in[i * channels + ch]; + } + } + free(data_in); + + if (samplerate != sf_info.samplerate) { + // SRC each channel into array of buffers data_resampled[] + double resample_ratio = (double)samplerate / sf_info.samplerate; + sf_count_t max_resampled_frames = frames * resample_ratio + 1; + + float* data_resampled[channels]; + sf_count_t resampled_frames = 0; + + int ch; + for (ch = 0; ch < channels; ++ch) { + data_resampled[ch] = malloc(max_resampled_frames * sizeof(float)); + + SRC_DATA src_data = { + .data_in = data_deinterleaved[ch], + .data_out = data_resampled[ch], + .input_frames = frames, + .output_frames = max_resampled_frames, + .src_ratio = resample_ratio, + .end_of_input = SF_TRUE + }; + + int err; + SRC_STATE *src = src_new(quality, 1, &err); + if (!src) { + error = ERROR_SRC; + fprintf(stderr, "SRC init error: %s\n", src_strerror(err)); + break; + } + + if ((err = src_process(src, &src_data))) { + error = ERROR_SRC; + fprintf(stderr, "SRC error: %s\n", src_strerror(err)); + break; + } + + src_delete(src); + free(data_deinterleaved[ch]); + data_deinterleaved[ch] = data_resampled[ch]; + if (ch == channels - 1) + frames = src_data.output_frames_gen; + } + + if (error) { + for (int i = ch; i < channels; ++i) + free(data_deinterleaved[i]); + return error; + } + } + + if (ratio != 1.0) { + // Stretch into array of buffers data_stretched[] + RubberBandState rb = rubberband_new( + samplerate, + channels, + RubberBandOptionProcessRealTime | + RubberBandOptionStretchElastic | + RubberBandOptionTransientsSmooth | + RubberBandOptionThreadingAuto, + ratio, + 1.0 // No pitch change + ); + + const int block_size = 512; + sf_count_t max_stretched_frames = frames * ratio + 2048; + float* data_stretched[channels]; + for (int ch = 0; ch < channels; ch++) + data_stretched[ch] = calloc(max_stretched_frames, sizeof(float)); + + sf_count_t stretched_frames = 0; + for (sf_count_t i = 0; i < frames; i += block_size) { + int n, final = 0; + if (i + block_size > frames) { + n = frames - i; + final = 1; + } else { + n = block_size; + } + float* block_in[channels]; + for (int ch = 0; ch < channels; ch++) + block_in[ch] = &data_deinterleaved[ch][i]; + + rubberband_process(rb, (const float* const*)block_in, n, final); + int available = rubberband_available(rb); + if (available > 0) { + float *block_out[channels]; + for (int ch = 0; ch < channels; ch++) + block_out[ch] = data_stretched[ch] + stretched_frames; + rubberband_retrieve(rb, block_out, available); + stretched_frames += available; + } + } + + int available = rubberband_available(rb); + if (available > 0) { + float *block_out[channels]; + for (int ch = 0; ch < channels; ch++) { + block_out[ch] = data_stretched[ch] + stretched_frames; + } + + rubberband_retrieve(rb, block_out, available); + stretched_frames += available; + } + + for (int ch = 0; ch < channels; ch++) { + free(data_deinterleaved[ch]); + data_deinterleaved[ch] = data_stretched[ch]; + } + frames = stretched_frames; + + rubberband_delete(rb); + } + + // Interleave into buffer data_out + float *data_out = malloc(frames * channels * sizeof(float)); + for (int ch = 0; ch < channels; ch++) { + for (sf_count_t i = 0; i < frames; i++) { + data_out[i * channels + ch] = data_deinterleaved[ch][i]; + } + free(data_deinterleaved[ch]); + } + + // Write output + sf_info.samplerate = samplerate; + sf_info.format = SF_FORMAT_WAV | SF_FORMAT_PCM_16; + + SNDFILE *outfile = sf_open(dst_path, SFM_WRITE, &sf_info); + if (!outfile) { + fprintf(stderr, "Failed to open output file.\n"); + return 1; + } + + sf_writef_float(outfile, data_out, frames); + sf_close(outfile); + + free(data_out); + return 0; +} diff --git a/zynlibs/zynclippy/clippy.h b/zynlibs/zynclippy/clippy.h new file mode 100644 index 000000000..135f8aab8 --- /dev/null +++ b/zynlibs/zynclippy/clippy.h @@ -0,0 +1,169 @@ +#include // Provides fixed width integer defininitions + +#define PRELOAD_FRAMES 2048 // Size of preload buffers in frames +#define RINGBUFFER PRELOAD_FRAMES * 2 // Size of ring buffers in frames +#define MAX_CLIPS 127 // Maximum quantity of clips per player/channel +#define MIN_FRAMES 1024 // Minimum quantity of frames to allow in audio files + +enum STATE { + STATE_IDLE, // Not ready for use + STATE_LOAD, // Load new file into preload cache + STATE_READY, // Cached, ready for use + STATE_STARTING, // Switch sndfile + STATE_PLAYING, // Buffer in use for playback (may be from preload or ring buffer) + STATE_STOPPING // Fade to avoid stop clitch +}; + +enum ERROR { + ERROR_SUCCESS, // No error + ERROR_EXISTS, // Already exists + ERROR_RANGE, // Parameter out of range + ERROR_CREATE, // Cannot create object + ERROR_PORT, // Cannot create port + ERROR_OPEN, // Error opening file + ERROR_SAMPLERATE, // Wrong samplerate + ERROR_ACTIVATE, // Cannot activate jack + ERROR_SRC, // Error during samplerate conversion + ERROR_STRETCH // Error during time stretch +}; + +enum MIDI_COMMANDS { + MIDI_NOTE_OFF = 0x80, + MIDI_NOTE_ON = 0x90, + MIDI_CC = 0xb0, + MIDI_POLYTOUCH = 0xa0, + MIDI_PROGRAM = 0xc0, + MIDI_AFTERTOUCH = 0xd0, + MIDI_PITCHBEND = 0xe0 +}; + +// ***Function declarations*** + +/** @brief Get the next available clip + @param channel MIDI channel + @retval uint8_t Clip ID (MIDI note) or 0 on error +*/ +uint8_t getFreeClip(uint8_t channel); + +/** @brief Load a file into a player + @param channel MIDI channel + @param note MIDI note to trigger clip or 0 for next available + @param path Full (or relative) path and filename + @retval uint8_t Clip ID (MIDI note) or 0 on error +*/ +uint8_t loadClip(uint8_t channel, uint8_t note, const char* path); + +/** @brief Unload a file from a player + @param channel MIDI channel + @param note MIDI note to trigger clip + @retval uint8_t Error code +*/ +uint8_t unloadClip(uint8_t channel, uint8_t note); + +/** @brief Create a new clip player + @brief channel MIDI channel for new player or 255 for next available channel + @retval uint8_t Channel number or 255 on error +*/ +uint8_t addPlayer(uint8_t channel); + +/** @brief Remove a clip player + @param channel MIDI channel + @retval uint8_t Error code +*/ +uint32_t removePlayer(uint8_t channel); + +//!@todo Remove clip manipulation (insert, remove, swap). + +/** @brief Insert clip + @param channel MIDI channel + @param clip Index of clip to insert + @retval uint8_t Error code + @note Moves existing clips up. Fails if no room. +*/ +uint8_t insertClip(uint8_t channel, uint8_t clip); + +/** @brief Remove clip + @param channel MIDI channel + @param clip Index of clip to remove + @retval uint8_t Error code + @note Moves existing clips down. +*/ +uint8_t removeClip(uint8_t channel, uint8_t clip); + +/** @brief Swap two clips + @param channel MIDI channel + @param clip1 Index of first clip + @param clip2 Index of second clip + @retval uint8_t Error code +*/ +uint8_t swapClip(uint8_t channel, uint8_t clip1, uint8_t clip2); + +/** @brief Set clip gain + @param channel MIDI channel + @param id Clip index + @param gain Gain factor (dB) + @retval uint8_t Error code +*/ +uint8_t setGain(uint8_t channel, uint8_t id, float gain); + +/** @brief Get clip gain + @param channel MIDI channel + @param id Clip index + @retval float Gain factor (dB) +*/ +float getGain(uint8_t channel, uint8_t id); + +//!@todo Remove cropping. + +/** @brief Set clip start offset + @param channel MIDI channel + @param id Clip index + @param start Start offset in frames + @retval uint8_t Error code +*/ +uint8_t setStart(uint8_t channel, uint8_t id, uint32_t start); + +/** @brief Get clip start offset + @param channel MIDI channel + @param id Clip index + @retval uint32_t Start offset in frames +*/ +uint32_t getStart(uint8_t channel, uint8_t id); + +/** @brief Set clip end offset + @param channel MIDI channel + @param id Clip index + @param end End offset in frames + @retval uint8_t Error code +*/ +uint8_t setEnd(uint8_t channel, uint8_t id, uint32_t end); + +/** @brief Get clip end offset + @param channel MIDI channel + @param id Clip index + @retval uint32_t End offset in frames +*/ +uint32_t getEnd(uint8_t channel, uint8_t id); + +/** @brief Get the samplerate of a file + @param path Full path and filename + @retval uint32_t Samplerate in frames per second or 0z0ero on error +*/ +uint32_t getFileSamplerate(const char* path); + +/** @brief Get the quantity of frames in a file + @param path Full path and filename + @retval uint32_t Duration in frames or 0 on error +*/ +uint32_t getFileFrames(const char* path); + +/** @brief Copy a file, applying samplerate conversion and time stretch + @param src_path Full path and filename of file to copy + @param dst_path Full path and filename of file to create + @param quality Samplerate quality + @param ratio Time stretch ratio (1.0 for no stretch) + @param start Start frame + @param end End frame + @retval int Error code +*/ +int copyFile(const char* src_path, const char* dst_path, uint8_t quality, float ratio, uint32_t start, uint32_t end); diff --git a/zynlibs/zynmixer/CMakeLists.txt b/zynlibs/zynmixer/CMakeLists.txt index c1849a0b3..a38eafdab 100644 --- a/zynlibs/zynmixer/CMakeLists.txt +++ b/zynlibs/zynmixer/CMakeLists.txt @@ -7,7 +7,10 @@ include(CheckLibraryExists) link_directories(/usr/local/lib) add_library(zynmixer SHARED mixer.h mixer.c tinyosc.h tinyosc.c) +add_library(zynmixer_mixbus SHARED mixer.h mixer.c tinyosc.h tinyosc.c) add_definitions(-Werror) target_link_libraries(zynmixer jack) +target_link_libraries(zynmixer_mixbus jack) +target_compile_definitions(zynmixer_mixbus PUBLIC -D MIXBUS) install(TARGETS zynmixer LIBRARY DESTINATION lib) diff --git a/zynlibs/zynmixer/mixer.c b/zynlibs/zynmixer/mixer.c index 0fee76c03..16adce2cb 100644 --- a/zynlibs/zynmixer/mixer.c +++ b/zynlibs/zynmixer/mixer.c @@ -4,7 +4,7 @@ * * Library providing stereo audio summing mixer * - * Copyright (C) 2019-2024 Brian Walton + * Copyright (C) 2019-2026 Brian Walton * * ****************************************************************** * @@ -32,23 +32,32 @@ #include "mixer.h" -#include "tinyosc.h" +#include "tinyosc.h" // provides OSC #include // provides inet_pton +// #define DEBUG + +#ifndef MAX_CHANNELS +#define MAX_CHANNELS 99 +#endif +#define MAX_OSC_CLIENTS 5 + +pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; char g_oscbuffer[1024]; // Used to send OSC messages -char g_oscpath[20]; //!@todo Ensure path length is sufficient for all paths, e.g. /mixer/faderxxx +char g_oscpath[64]; //!@todo Ensure path length is sufficient for all paths, e.g. /mixer/channel/xx/fader int g_oscfd = -1; // File descriptor for OSC socket int g_bOsc = 0; // True if OSC client subscribed pthread_t g_eventThread; // ID of low priority event thread int g_sendEvents = 1; // Set to 0 to exit event thread -int g_solo = 0; // True if any channel solo enabled - -// #define DEBUG - -#define MAX_CHANNELS 17 -#define MAX_OSC_CLIENTS 5 - -struct dynamic { +uint8_t g_sendCount = 0; // Quantity of effect sends +uint8_t g_lastStrip = 1; // Highest index of any strips (one-based) +uint8_t g_lastSend = 1; // Highest index of any send (one-based) +uint8_t g_solo = 0; // Quantity of channels with solo asserted +jack_port_t* g_soloPortA; // Pointer to solo trunk port A +jack_port_t* g_soloPortB; // Pointer to solo trunk port B + +// Structure describing a channel strip +struct channel_strip { jack_port_t* inPortA; // Jack input port A jack_port_t* inPortB; // Jack input port B jack_port_t* outPortA; // Jack output port A @@ -57,34 +66,54 @@ struct dynamic { float reqlevel; // Requested fader level 0..1 float balance; // Current balance -1..+1 float reqbalance; // Requested balance -1..+1 + float send[MAX_CHANNELS]; // Current fx send levels float dpmA; // Current peak programme A-leg float dpmB; // Current peak programme B-leg float holdA; // Current peak hold level A-leg float holdB; // Current peak hold level B-leg + float dpmAlast; // Last peak programme A-leg + float dpmBlast; // Last peak programme B-leg + float holdAlast; // Last peak hold level A-leg + float holdBlast; // Last peak hold level B-leg uint8_t mute; // 1 if muted - uint8_t solo; // 1 if solo uint8_t mono; // 1 if mono + uint8_t solo; // 1 if solo uint8_t ms; // 1 if MS decoding uint8_t phase; // 1 if channel B phase reversed - uint8_t normalise; // 1 if channel normalised to main output (when output not routed) + uint8_t sendMode[MAX_CHANNELS]; // 0: post-fader send, 1: pre-fader send + uint8_t normalise; // 1 if channel normalised to main output uint8_t inRouted; // 1 if source routed to channel uint8_t outRouted; // 1 if output routed uint8_t enable_dpm; // 1 to enable calculation of peak meter }; -jack_client_t* g_pJackClient; -struct dynamic g_dynamic[MAX_CHANNELS]; -struct dynamic g_dynamic_last[MAX_CHANNELS]; // Previous values used to thin OSC updates +struct fx_send { + jack_port_t* outPortA; // Jack output port A + jack_port_t* outPortB; // Jack output port B + jack_default_audio_sample_t* bufferA; // Holds audio samples + jack_default_audio_sample_t* bufferB; // Holds audio samples + float level; // Current fader level 0..1 +}; + +jack_client_t* g_jackClient; +struct channel_strip* g_channelStrips[MAX_CHANNELS]; +#ifndef MIXBUS +struct fx_send* g_fxSends[MAX_CHANNELS]; +#endif unsigned int g_nDampingCount = 0; unsigned int g_nDampingPeriod = 10; // Quantity of cycles between applying DPM damping decay unsigned int g_nHoldCount = 0; float g_fDpmDecay = 0.9; // Factor to scale for DPM decay - defines resolution of DPM decay struct sockaddr_in g_oscClient[MAX_OSC_CLIENTS]; // Array of registered OSC clients char g_oscdpm[20]; -jack_nframes_t g_samplerate = 44100; // Jack samplerate used to calculate damping factor +jack_nframes_t g_samplerate = 48000; // Jack samplerate used to calculate damping factor jack_nframes_t g_buffersize = 1024; // Jack buffer size used to calculate damping factor -jack_default_audio_sample_t* pNormalisedBufferA = NULL; // Pointer to buffer for normalised audio -jack_default_audio_sample_t* pNormalisedBufferB = NULL; // Pointer to buffer for normalised audio +jack_default_audio_sample_t* g_soloBufferA = NULL; // Ponter to buffer used for solo bus +jack_default_audio_sample_t* g_soloBufferB = NULL; // Ponter to buffer used for solo bus +#ifdef MIXBUS +jack_default_audio_sample_t* g_mainNormaliseBufferA = NULL; // Ponter to main output normalised buffer used for normalising effects sends to main mixbus +jack_default_audio_sample_t* g_mainNormaliseBufferB = NULL; // Ponter to main output normalised buffer used for normalising effects sends to main mixbus +#endif static float convertToDBFS(float raw) { if (raw <= 0) @@ -121,125 +150,175 @@ void sendOscInt(const char* path, int value) { void* eventThreadFn(void* param) { while (g_sendEvents) { if (g_bOsc) { - for (unsigned int chan = 0; chan < MAX_CHANNELS; chan++) { - if ((int)(100000 * g_dynamic_last[chan].dpmA) != (int)(100000 * g_dynamic[chan].dpmA)) { - sprintf(g_oscdpm, "/mixer/dpm%da", chan); - sendOscFloat(g_oscdpm, convertToDBFS(g_dynamic[chan].dpmA)); - g_dynamic_last[chan].dpmA = g_dynamic[chan].dpmA; - } - if ((int)(100000 * g_dynamic_last[chan].dpmB) != (int)(100000 * g_dynamic[chan].dpmB)) { - sprintf(g_oscdpm, "/mixer/dpm%db", chan); - sendOscFloat(g_oscdpm, convertToDBFS(g_dynamic[chan].dpmB)); - g_dynamic_last[chan].dpmB = g_dynamic[chan].dpmB; - } - if ((int)(100000 * g_dynamic_last[chan].holdA) != (int)(100000 * g_dynamic[chan].holdA)) { - sprintf(g_oscdpm, "/mixer/hold%da", chan); - sendOscFloat(g_oscdpm, convertToDBFS(g_dynamic[chan].holdA)); - g_dynamic_last[chan].holdA = g_dynamic[chan].holdA; - } - if ((int)(100000 * g_dynamic_last[chan].holdB) != (int)(100000 * g_dynamic[chan].holdB)) { - sprintf(g_oscdpm, "/mixer/hold%db", chan); - sendOscFloat(g_oscdpm, convertToDBFS(g_dynamic[chan].holdB)); - g_dynamic_last[chan].holdB = g_dynamic[chan].holdB; + for (unsigned int chan = 0; chan < MAX_CHANNELS; ++chan) { + if (g_channelStrips[chan]) { + if ((int)(100000 * g_channelStrips[chan]->dpmAlast) != (int)(100000 * g_channelStrips[chan]->dpmA)) { + sprintf(g_oscdpm, "/mixer/channel/%d/dpma", chan); + sendOscFloat(g_oscdpm, convertToDBFS(g_channelStrips[chan]->dpmA)); + g_channelStrips[chan]->dpmAlast = g_channelStrips[chan]->dpmA; + } + if ((int)(100000 * g_channelStrips[chan]->dpmBlast) != (int)(100000 * g_channelStrips[chan]->dpmB)) { + sprintf(g_oscdpm, "/mixer/channel/%d/dpmb", chan); + sendOscFloat(g_oscdpm, convertToDBFS(g_channelStrips[chan]->dpmB)); + g_channelStrips[chan]->dpmBlast = g_channelStrips[chan]->dpmB; + } + if ((int)(100000 * g_channelStrips[chan]->holdAlast) != (int)(100000 * g_channelStrips[chan]->holdA)) { + sprintf(g_oscdpm, "/mixer/channel/%d/holda", chan); + sendOscFloat(g_oscdpm, convertToDBFS(g_channelStrips[chan]->holdA)); + g_channelStrips[chan]->holdAlast = g_channelStrips[chan]->holdA; + } + if ((int)(100000 * g_channelStrips[chan]->holdBlast) != (int)(100000 * g_channelStrips[chan]->holdB)) { + sprintf(g_oscdpm, "/mixer/channel/%d/holdb", chan); + sendOscFloat(g_oscdpm, convertToDBFS(g_channelStrips[chan]->holdB)); + g_channelStrips[chan]->holdBlast = g_channelStrips[chan]->holdB; + } } } } usleep(10000); } - pthread_exit(NULL); } -static int onJackProcess(jack_nframes_t nFrames, void* pArgs) { - jack_default_audio_sample_t *pInA, *pInB, *pOutA, *pOutB, *pChanOutA, *pChanOutB; +static int onJackProcess(jack_nframes_t frames, void* args) { + jack_default_audio_sample_t *pSoloA, *pSoloB, *pInA, *pInB, *pOutA, *pOutB, *pChanOutA, *pChanOutB, *pMainOutA, *pMainOutB; + unsigned int frame; + float curLevelA, curLevelB, reqLevelA, reqLevelB, fDeltaA, fDeltaB, fSampleA, fSampleB, fSampleM, fpreFaderSampleA, fpreFaderSampleB; + + pthread_mutex_lock(&mutex); - unsigned int frame, chan; - float curLevelA, curLevelB, reqLevelA, reqLevelB, fDeltaA, fDeltaB, fSampleA, fSampleB, fSampleM; +/* Solo + The chain mixer has a pair of buffers (A/B) that are cleared at start of period, then populated with samples of any inputs that are solo. + These buffers are pushed to its solo ouptut ports. + The mixbus mixer has a pair of buffers (A/B) that are populated from its solo input ports, then summed with samples of any inputs that are solo. (Avoid chan 0.) + These buffers are pushed to the solo monitor outputs (default is main outputs). +*/ - // Clear the normalisation buffer. This will be populated by each channel then used in final channel iteration - memset(pNormalisedBufferA, 0.0, nFrames * sizeof(jack_default_audio_sample_t)); - memset(pNormalisedBufferB, 0.0, nFrames * sizeof(jack_default_audio_sample_t)); + if (g_solo) { + pSoloA = jack_port_get_buffer(g_soloPortA, frames); + pSoloB = jack_port_get_buffer(g_soloPortB, frames); + } - // Process each channel - for (chan = 0; chan < MAX_CHANNELS; chan++) { - if (isChannelRouted(chan) || (chan == (MAX_CHANNELS - 1))) { - //**Calculate processing levels** +#ifdef MIXBUS + // Clear the main mixbus output buffers to allow them to be directly populated with effects return normalisd frames. + memset(g_mainNormaliseBufferA, 0.0, frames * sizeof(jack_default_audio_sample_t)); + memset(g_mainNormaliseBufferB, 0.0, frames * sizeof(jack_default_audio_sample_t)); + // Populate solo buffers from trunk + if (g_solo) { + memcpy(g_soloBufferA, pSoloA, frames * sizeof(jack_default_audio_sample_t)); + memcpy(g_soloBufferB, pSoloB, frames * sizeof(jack_default_audio_sample_t)); + } +#else + // Clear solo send buffers + if (g_solo) { + memset(pSoloA, 0.0, frames * sizeof(jack_default_audio_sample_t)); + memset(pSoloB, 0.0, frames * sizeof(jack_default_audio_sample_t)); + g_soloBufferA = pSoloA; // We will populate the trunk directly + g_soloBufferB = pSoloB; + } + // Clear send buffers. + for (uint8_t send = 0; send < MAX_CHANNELS; ++send) { + if (g_fxSends[send]) { + memset(g_fxSends[send]->bufferA, 0.0, frames * sizeof(jack_default_audio_sample_t)); + memset(g_fxSends[send]->bufferB, 0.0, frames * sizeof(jack_default_audio_sample_t)); + } + } +#endif + + // Process each channel in reverse order (so that main mixbus is last) + uint8_t chan = g_lastStrip; + while (chan--) { + struct channel_strip* strip = g_channelStrips[chan]; + if (strip == NULL) + continue; + + // Only process connected inputs and mixbuses + if (strip->inRouted) { // Calculate current (last set) balance - if (g_dynamic[chan].balance > 0.0) - curLevelA = g_dynamic[chan].level * (1 - g_dynamic[chan].balance); + if (strip->balance > 0.0) + curLevelA = strip->level * (1 - strip->balance); else - curLevelA = g_dynamic[chan].level; - if (g_dynamic[chan].balance < 0.0) - curLevelB = g_dynamic[chan].level * (1 + g_dynamic[chan].balance); + curLevelA = strip->level; + if (strip->balance < 0.0) + curLevelB = strip->level * (1 + strip->balance); else - curLevelB = g_dynamic[chan].level; + curLevelB = strip->level; // Calculate mute and target level and balance (that we will fade to over this cycle period to avoid abrupt change clicks) - if (g_dynamic[chan].mute || g_solo && (chan < MAX_CHANNELS - 1) && g_dynamic[chan].solo != 1) { - // Do not mute aux if solo enabled - g_dynamic[chan].level = 0; // We can set this here because we have the data and will iterate towards 0 over this frame + //!@todo Crossfade send levels + if (strip->mute) { + strip->level = 0; // We can set this here because we have the data and will iterate towards 0 over this frame reqLevelA = 0.0; reqLevelB = 0.0; } else { - if (g_dynamic[chan].reqbalance > 0.0) - reqLevelA = g_dynamic[chan].reqlevel * (1 - g_dynamic[chan].reqbalance); + if (strip->reqbalance > 0.0) + reqLevelA = strip->reqlevel * (1 - strip->reqbalance); else - reqLevelA = g_dynamic[chan].reqlevel; - if (g_dynamic[chan].reqbalance < 0.0) - reqLevelB = g_dynamic[chan].reqlevel * (1 + g_dynamic[chan].reqbalance); + reqLevelA = strip->reqlevel; + if (strip->reqbalance < 0.0) + reqLevelB = strip->reqlevel * (1 + strip->reqbalance); else - reqLevelB = g_dynamic[chan].reqlevel; - g_dynamic[chan].level = g_dynamic[chan].reqlevel; - g_dynamic[chan].balance = g_dynamic[chan].reqbalance; + reqLevelB = strip->reqlevel; + strip->level = strip->reqlevel; + strip->balance = strip->reqbalance; } // Calculate the step change for each leg to apply on each sample in buffer for fade between last and this period's level - fDeltaA = (reqLevelA - curLevelA) / nFrames; - fDeltaB = (reqLevelB - curLevelB) / nFrames; + fDeltaA = (reqLevelA - curLevelA) / frames; + fDeltaB = (reqLevelB - curLevelB) / frames; // **Apply processing to audio samples** - - pInA = jack_port_get_buffer(g_dynamic[chan].inPortA, nFrames); - pInB = jack_port_get_buffer(g_dynamic[chan].inPortB, nFrames); - - if (isChannelOutRouted(chan)) { - // Direct output so create audio buffers - pChanOutA = jack_port_get_buffer(g_dynamic[chan].outPortA, nFrames); - pChanOutB = jack_port_get_buffer(g_dynamic[chan].outPortB, nFrames); - memset(pChanOutA, 0.0, nFrames * sizeof(jack_default_audio_sample_t)); - memset(pChanOutB, 0.0, nFrames * sizeof(jack_default_audio_sample_t)); + pInA = jack_port_get_buffer(strip->inPortA, frames); + pInB = jack_port_get_buffer(strip->inPortB, frames); + + if (strip->outRouted) { + // Direct output so prepare output audio buffers + pChanOutA = jack_port_get_buffer(strip->outPortA, frames); + pChanOutB = jack_port_get_buffer(strip->outPortB, frames); + memset(pChanOutA, 0.0, frames * sizeof(jack_default_audio_sample_t)); + memset(pChanOutB, 0.0, frames * sizeof(jack_default_audio_sample_t)); } else { pChanOutA = pChanOutB = NULL; } - // Iterate samples, scaling each and adding to output and set DPM if any samples louder than current DPM - for (frame = 0; frame < nFrames; frame++) { - if (chan == MAX_CHANNELS - 1) { - // Mix channel input and normalised channels mix - fSampleA = (pInA[frame] + pNormalisedBufferA[frame]); - fSampleB = (pInB[frame] + pNormalisedBufferB[frame]); + for (frame = 0; frame < frames; ++frame) { +#ifdef MIXBUS + if (chan == 0) { + if (g_solo) { + fSampleA = g_soloBufferA[frame]; + fSampleB = g_soloBufferB[frame]; + } else { + fSampleA = pInA[frame] + g_mainNormaliseBufferA[frame]; + fSampleB = pInB[frame] + g_mainNormaliseBufferB[frame]; + } } else { fSampleA = pInA[frame]; fSampleB = pInB[frame]; } +#else + fSampleA = pInA[frame]; + fSampleB = pInB[frame]; +#endif // Handle channel phase reverse - if (g_dynamic[chan].phase) + if (strip->phase) fSampleB = -fSampleB; // Decode M+S - if (g_dynamic[chan].ms) { + if (strip->ms) { fSampleM = fSampleA + fSampleB; fSampleB = fSampleA - fSampleB; fSampleA = fSampleM; } // Handle mono - if (g_dynamic[chan].mono) { + if (strip->mono) { fSampleA = (fSampleA + fSampleB) / 2.0; fSampleB = fSampleA; } // Apply level adjustment + fpreFaderSampleA = fSampleA; + fpreFaderSampleB = fSampleB; fSampleA *= curLevelA; fSampleB *= curLevelB; @@ -248,56 +327,82 @@ static int onJackProcess(jack_nframes_t nFrames, void* pArgs) { fSampleA = 1.0; if (isinf(fSampleB)) fSampleB = 1.0; + if (isinf(fpreFaderSampleA)) + fpreFaderSampleA = 1.0; + if (isinf(fpreFaderSampleB)) + fpreFaderSampleB = 1.0; // Write sample to output buffer if (pChanOutA) { pChanOutA[frame] += fSampleA; pChanOutB[frame] += fSampleB; } - // Write normalised samples - if (chan < MAX_CHANNELS - 1 && g_dynamic[chan].normalise) { - pNormalisedBufferA[frame] += fSampleA; - pNormalisedBufferB[frame] += fSampleB; + if (strip->solo) { + g_soloBufferA[frame] += fSampleA; + g_soloBufferB[frame] += fSampleB; } - +#ifdef MIXBUS + // Add frames to main mixbus normalise buffer + if (strip->normalise) { + g_mainNormaliseBufferA[frame] += fSampleA; + g_mainNormaliseBufferB[frame] += fSampleB; + } +#else + // Add fx send output frames only for input channels + for (uint8_t send = 0; send < g_lastSend; ++send) { + if (g_fxSends[send]) { + if (strip->sendMode[send] == 0) { + g_fxSends[send]->bufferA[frame] += fSampleA * strip->send[send] * g_fxSends[send]->level; + g_fxSends[send]->bufferB[frame] += fSampleB * strip->send[send] * g_fxSends[send]->level; + } else if (strip->sendMode[send] == 1) { + g_fxSends[send]->bufferA[frame] += fpreFaderSampleA * strip->send[send] * g_fxSends[send]->level; + g_fxSends[send]->bufferB[frame] += fpreFaderSampleB * strip->send[send] * g_fxSends[send]->level; + } + if(isinf(g_fxSends[send]->bufferA[frame])) + g_fxSends[send]->bufferA[frame] = 1.0; + if(isinf(g_fxSends[send]->bufferB[frame])) + g_fxSends[send]->bufferB[frame] = 1.0; + } + } +#endif curLevelA += fDeltaA; curLevelB += fDeltaB; // Process DPM - if (g_dynamic[chan].enable_dpm) { + if (strip->enable_dpm) { fSampleA = fabs(fSampleA); - if (fSampleA > g_dynamic[chan].dpmA) - g_dynamic[chan].dpmA = fSampleA; + if (fSampleA > strip->dpmA) + strip->dpmA = fSampleA; fSampleB = fabs(fSampleB); - if (fSampleB > g_dynamic[chan].dpmB) - g_dynamic[chan].dpmB = fSampleB; + if (fSampleB > strip->dpmB) + strip->dpmB = fSampleB; // Update peak hold and scale DPM for damped release - if (g_dynamic[chan].dpmA > g_dynamic[chan].holdA) - g_dynamic[chan].holdA = g_dynamic[chan].dpmA; - if (g_dynamic[chan].dpmB > g_dynamic[chan].holdB) - g_dynamic[chan].holdB = g_dynamic[chan].dpmB; + if (strip->dpmA > strip->holdA) + strip->holdA = strip->dpmA; + if (strip->dpmB > strip->holdB) + strip->holdB = strip->dpmB; } } if (g_nHoldCount == 0) { // Only update peak hold each g_nHoldCount cycles - g_dynamic[chan].holdA = g_dynamic[chan].dpmA; - g_dynamic[chan].holdB = g_dynamic[chan].dpmB; + strip->holdA = strip->dpmA; + strip->holdB = strip->dpmB; } if (g_nDampingCount == 0) { // Only update damping release each g_nDampingCount cycles - g_dynamic[chan].dpmA *= g_fDpmDecay; - g_dynamic[chan].dpmB *= g_fDpmDecay; + strip->dpmA *= g_fDpmDecay; + strip->dpmB *= g_fDpmDecay; } - } else if (g_dynamic[chan].enable_dpm) { - g_dynamic[chan].dpmA = -200.0; - g_dynamic[chan].dpmB = -200.0; - g_dynamic[chan].holdA = -200.0; - g_dynamic[chan].holdB = -200.0; + } else if (strip->enable_dpm) { + strip->dpmA = 0.0f; + strip->dpmB = 0.0f; + strip->holdA = 0.0f; + strip->holdB = 0.0f; } } - if (g_nDampingCount == 0) +if (g_nDampingCount == 0) g_nDampingCount = g_nDampingPeriod; else --g_nDampingCount; @@ -306,21 +411,40 @@ static int onJackProcess(jack_nframes_t nFrames, void* pArgs) { else --g_nHoldCount; + pthread_mutex_unlock(&mutex); return 0; } +void print_dpm_info(uint8_t chan) { + // Debug helper to print current DPM state + struct channel_strip* strip = g_channelStrips[chan]; + if (strip) + fprintf(stderr, "A: %f\nB: %f\nHold A: %f\nHold B: %f\n%s\nHold count: %u\nDamping period: %u\n", + strip->dpmA, + strip->dpmB, + strip->holdA, + strip->holdB, + strip->enable_dpm?"Enabled":"Disabled", + g_nHoldCount, + g_nDampingPeriod + ); +} + void onJackConnect(jack_port_id_t source, jack_port_id_t dest, int connect, void* args) { - uint8_t chan; - for (chan = 0; chan < MAX_CHANNELS; chan++) { - if (jack_port_connected(g_dynamic[chan].inPortA) > 0 || (jack_port_connected(g_dynamic[chan].inPortB) > 0)) - g_dynamic[chan].inRouted = 1; + pthread_mutex_lock(&mutex); + for (uint8_t chan = 0; chan < MAX_CHANNELS; chan++) { + if (g_channelStrips[chan] == NULL) + continue; + if (jack_port_connected(g_channelStrips[chan]->inPortA) > 0 || (jack_port_connected(g_channelStrips[chan]->inPortB) > 0)) + g_channelStrips[chan]->inRouted = 1; else - g_dynamic[chan].inRouted = 0; - if (jack_port_connected(g_dynamic[chan].outPortA) > 0 || (jack_port_connected(g_dynamic[chan].outPortB) > 0)) - g_dynamic[chan].outRouted = 1; + g_channelStrips[chan]->inRouted = 0; + if (jack_port_connected(g_channelStrips[chan]->outPortA) > 0 || (jack_port_connected(g_channelStrips[chan]->outPortB) > 0)) + g_channelStrips[chan]->outRouted = 1; else - g_dynamic[chan].outRouted = 0; + g_channelStrips[chan]->outRouted = 0; } + pthread_mutex_unlock(&mutex); } int onJackSamplerate(jack_nframes_t nSamplerate, void* arg) { @@ -336,14 +460,36 @@ int onJackBuffersize(jack_nframes_t nBuffersize, void* arg) { return 0; g_buffersize = nBuffersize; g_nDampingPeriod = g_fDpmDecay * g_samplerate / g_buffersize / 15; - free(pNormalisedBufferA); - free(pNormalisedBufferB); - pNormalisedBufferA = malloc(nBuffersize * sizeof(jack_default_audio_sample_t)); - pNormalisedBufferB = malloc(nBuffersize * sizeof(jack_default_audio_sample_t)); + pthread_mutex_lock(&mutex); + free(g_soloBufferA); + free(g_soloBufferB); + g_soloBufferA = malloc(sizeof(jack_nframes_t) * g_buffersize); + g_soloBufferB = malloc(sizeof(jack_nframes_t) * g_buffersize); +#ifdef MIXBUS + free(g_mainNormaliseBufferA); + free(g_mainNormaliseBufferB); + g_mainNormaliseBufferA = malloc(sizeof(jack_nframes_t) * g_buffersize); + g_mainNormaliseBufferB = malloc(sizeof(jack_nframes_t) * g_buffersize); +#else + for (uint8_t chan = 0; chan < MAX_CHANNELS; ++chan) { + if (g_fxSends[chan]) { + g_fxSends[chan]->bufferA = jack_port_get_buffer(g_fxSends[chan]->outPortA, g_buffersize); + g_fxSends[chan]->bufferB = jack_port_get_buffer(g_fxSends[chan]->outPortB, g_buffersize); + } + } +#endif + pthread_mutex_unlock(&mutex); return 0; } int init() { + for (uint8_t chan = 0; chan < MAX_CHANNELS; ++chan) { + g_channelStrips[chan] = NULL; +#ifndef MIXBUS + g_fxSends[chan] = NULL; +#endif + } + // Initialsize OSC g_oscfd = socket(AF_INET, SOCK_DGRAM, 0); for (uint8_t i = 0; i < MAX_OSC_CLIENTS; ++i) { @@ -357,67 +503,61 @@ int init() { char* sServerName = NULL; jack_status_t nStatus; jack_options_t nOptions = JackNoStartServer; - - if ((g_pJackClient = jack_client_open("zynmixer", nOptions, &nStatus, sServerName)) == 0) { - fprintf(stderr, "libzynmixer: Failed to start jack client: %d\n", nStatus); + #ifdef MIXBUS + const char* jackname = "zynmixer_bus"; + #else + const char* jackname = "zynmixer_chan"; + #endif + if ((g_jackClient = jack_client_open(jackname, nOptions, &nStatus, sServerName)) == 0) { + fprintf(stderr, "libzynmixer: Failed to start channel jack client: %d\n", nStatus); exit(1); } #ifdef DEBUG fprintf(stderr, "libzynmixer: Registering as '%s'.\n", jack_get_client_name(g_pJackClient)); #endif - // Create input ports - for (size_t chan = 0; chan < MAX_CHANNELS; ++chan) { - g_dynamic[chan].level = 0.0; - g_dynamic[chan].reqlevel = 0.8; - g_dynamic[chan].balance = 0.0; - g_dynamic[chan].reqbalance = 0.0; - g_dynamic[chan].mute = 0; - g_dynamic[chan].ms = 0; - g_dynamic[chan].phase = 0; - g_dynamic[chan].enable_dpm = 1; - g_dynamic[chan].normalise = 1; - char sName[11]; - sprintf(sName, "input_%02lda", chan + 1); - if (!(g_dynamic[chan].inPortA = jack_port_register(g_pJackClient, sName, JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0))) { - fprintf(stderr, "libzynmixer: Cannot register %s\n", sName); - exit(1); - } - sprintf(sName, "input_%02ldb", chan + 1); - if (!(g_dynamic[chan].inPortB = jack_port_register(g_pJackClient, sName, JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0))) { - fprintf(stderr, "libzynmixer: Cannot register %s\n", sName); - exit(1); - } - sprintf(sName, "output_%02lda", chan + 1); - if (!(g_dynamic[chan].outPortA = jack_port_register(g_pJackClient, sName, JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0))) { - fprintf(stderr, "libzynmixer: Cannot register %s\n", sName); - exit(1); - } - sprintf(sName, "output_%02ldb", chan + 1); - if (!(g_dynamic[chan].outPortB = jack_port_register(g_pJackClient, sName, JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0))) { - fprintf(stderr, "libzynmixer: Cannot register %s\n", sName); - exit(1); - } - g_dynamic_last[chan].dpmA = 100.0; - g_dynamic_last[chan].dpmB = 100.0; - g_dynamic_last[chan].holdA = 100.0; - g_dynamic_last[chan].holdB = 100.0; + // Solo ports +#ifdef MIXBUS + unsigned long solo_port_flags = JackPortIsInput; +#else + unsigned long solo_port_flags = JackPortIsOutput; +#endif + if (!(g_soloPortA = jack_port_register(g_jackClient, "solo_a", JACK_DEFAULT_AUDIO_TYPE, solo_port_flags, 0))) { + fprintf(stderr, "libzynmixer: Cannot register %s\n", "solo_a"); + return -1; + } + if (!(g_soloPortB = jack_port_register(g_jackClient, "solo_b", JACK_DEFAULT_AUDIO_TYPE, solo_port_flags, 0))) { + fprintf(stderr, "libzynmixer: Cannot register %s\n", "solo_b"); + jack_port_unregister(g_jackClient, g_soloPortA); + return -1; } + g_soloBufferA = malloc(sizeof(jack_nframes_t) * g_buffersize); + g_soloBufferB = malloc(sizeof(jack_nframes_t) * g_buffersize); + +#ifdef MIXBUS + int8_t id = addStrip(); // Main mixbus + id = addStrip(); // Aux mixbus + setLevel(id, 1.0); // Default unity gain for aux bus + setNormalise(id, 1); + g_mainNormaliseBufferA = malloc(sizeof(jack_nframes_t) * g_buffersize); + g_mainNormaliseBufferB = malloc(sizeof(jack_nframes_t) * g_buffersize); +#endif #ifdef DEBUG - fprintf(stderr, "libzynmixer: Created input ports\n"); + fprintf(stderr, "libzynmixer: Created channel strips\n"); #endif // Register the cleanup function to be called when library exits atexit(end); // Register the callbacks - jack_set_process_callback(g_pJackClient, onJackProcess, 0); - jack_set_port_connect_callback(g_pJackClient, onJackConnect, 0); - jack_set_sample_rate_callback(g_pJackClient, onJackSamplerate, 0); - jack_set_buffer_size_callback(g_pJackClient, onJackBuffersize, 0); + jack_set_process_callback(g_jackClient, onJackProcess, NULL); + jack_set_port_connect_callback(g_jackClient, onJackConnect, NULL); + jack_set_sample_rate_callback(g_jackClient, onJackSamplerate, NULL); + jack_set_buffer_size_callback(g_jackClient, onJackBuffersize, NULL); - if (jack_activate(g_pJackClient)) { + + if (jack_activate(g_jackClient)) { fprintf(stderr, "libzynmixer: Cannot activate client\n"); exit(1); } @@ -435,261 +575,502 @@ int init() { return 0; } - fprintf(stderr, "Started libzynmixer\n"); - + fprintf(stderr, "Started %s\n", jackname); return 1; } void end() { - if (g_pJackClient) { - // Mute output and wait for soft mute to occur before closing link with jack server - setLevel(MAX_CHANNELS - 1, 0.0); - usleep(100000); - // jack_client_close(g_pJackClient); - } g_sendEvents = 0; - free(pNormalisedBufferA); - free(pNormalisedBufferB); - void* status; pthread_join(g_eventThread, &status); + + //Soft mute output + setLevel(0, 0.0); + usleep(100000); + + // Close links with jack server + if (g_jackClient) { + jack_deactivate(g_jackClient); + jack_client_close(g_jackClient); + } + + // Release dynamically created resources + free(g_soloBufferA); + free(g_soloBufferB); +#ifdef MIXBUS + free(g_mainNormaliseBufferA); + free(g_mainNormaliseBufferB); +#endif + for (uint8_t chan = 0; chan < MAX_CHANNELS; ++chan) { + free(g_channelStrips[chan]); +#ifndef MIXBUS + free(g_fxSends[chan]); +#endif + } + fprintf(stderr, "zynmixer ended\n"); } void setLevel(uint8_t channel, float level) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - else - g_dynamic[channel].reqlevel = level; - sprintf(g_oscpath, "/mixer/fader%d", channel); + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + g_channelStrips[channel]->reqlevel = level; + sprintf(g_oscpath, "/mixer/channel/%d/fader", channel); sendOscFloat(g_oscpath, level); } float getLevel(uint8_t channel) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - return g_dynamic[channel].reqlevel; + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return 0.0f; + return g_channelStrips[channel]->reqlevel; } void setBalance(uint8_t channel, float balance) { + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; if (fabs(balance) > 1) return; - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - g_dynamic[channel].reqbalance = balance; - sprintf(g_oscpath, "/mixer/balance%d", channel); + g_channelStrips[channel]->reqbalance = balance; + sprintf(g_oscpath, "/mixer/channel/%d/balance", channel); sendOscFloat(g_oscpath, balance); } float getBalance(uint8_t channel) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - return g_dynamic[channel].reqbalance; + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return 0.0f; + return g_channelStrips[channel]->reqbalance; } void setMute(uint8_t channel, uint8_t mute) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - g_dynamic[channel].mute = mute; - sprintf(g_oscpath, "/mixer/mute%d", channel); + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + g_channelStrips[channel]->mute = mute; + sprintf(g_oscpath, "/mixer/channel/%d/mute", channel); sendOscInt(g_oscpath, mute); } uint8_t getMute(uint8_t channel) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - return g_dynamic[channel].mute; + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return 0; + return g_channelStrips[channel]->mute; +} + +void toggleMute(uint8_t channel) { + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + uint8_t mute; + mute = g_channelStrips[channel]->mute; + if (mute) + setMute(channel, 0); + else + setMute(channel, 1); +} + +void setSolo(uint8_t channel, uint8_t solo) { + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + solo = solo?1:0; + if (g_channelStrips[channel]->solo == solo) + return; + g_channelStrips[channel]->solo = solo; + if (solo) + ++g_solo; + else + --g_solo; + sprintf(g_oscpath, "/mixer/channel/%d/solo", channel); + sendOscInt(g_oscpath, solo); +} + +uint8_t getSolo(uint8_t channel) { + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return 0; + return g_channelStrips[channel]->solo; +} + +void toggleSolo(uint8_t channel) { + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + uint8_t solo; + solo = g_channelStrips[channel]->mute; + if (solo) + setSolo(channel, 0); + else + setSolo(channel, 1); +} + +void clearSolo() { + for (uint8_t channel = 0; channel < MAX_CHANNELS; ++channel) { + if (g_channelStrips[channel]) + g_channelStrips[channel]->solo = 0; + } + g_solo = 0; +} + +uint8_t getGlobalSolo() { + return g_solo; } void setPhase(uint8_t channel, uint8_t phase) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - g_dynamic[channel].phase = phase; - sprintf(g_oscpath, "/mixer/phase%d", channel); + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + g_channelStrips[channel]->phase = phase; + sprintf(g_oscpath, "/mixer/channel/%d/phase", channel); sendOscInt(g_oscpath, phase); } uint8_t getPhase(uint8_t channel) { - if (channel >= MAX_CHANNELS) + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) return 0; - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - return g_dynamic[channel].phase; + return g_channelStrips[channel]->phase; } -void setNormalise(uint8_t channel, uint8_t enable) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - g_dynamic[channel].normalise = enable; - sprintf(g_oscpath, "/mixer/normalise%d", channel); - sendOscInt(g_oscpath, enable); +void setSendMode(uint8_t channel, uint8_t send, uint8_t mode) { + if (channel >= MAX_CHANNELS || send >= MAX_CHANNELS || g_channelStrips[channel] == NULL || mode > 1) + return; + g_channelStrips[channel]->sendMode[send] = mode; + sprintf(g_oscpath, "/mixer/channel/%d/sendmode_%d", channel, send); + sendOscInt(g_oscpath, mode); } -uint8_t getNormalise(uint8_t channel, uint8_t enable) { - if (channel >= MAX_CHANNELS) +uint8_t getSendMode(uint8_t channel, uint8_t send) { + if (channel >= MAX_CHANNELS || send >= MAX_CHANNELS || g_channelStrips[channel] == NULL) return 0; - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - return g_dynamic[channel].normalise; + return g_channelStrips[channel]->sendMode[send]; } -void setSolo(uint8_t channel, uint8_t solo) { - if (channel + 1 >= MAX_CHANNELS) { - // Setting main mixbus solo will disable all channel solos - for (uint8_t nChannel = 0; nChannel < MAX_CHANNELS - 1; ++nChannel) { - g_dynamic[nChannel].solo = 0; - sprintf(g_oscpath, "/mixer/solo%d", nChannel); - sendOscInt(g_oscpath, 0); - } - } else { - g_dynamic[channel].solo = solo; - sprintf(g_oscpath, "/mixer/solo%d", channel); - sendOscInt(g_oscpath, solo); - } - // Set the global solo flag if any channel solo is enabled - g_solo = 0; - for (uint8_t nChannel = 0; nChannel < MAX_CHANNELS - 1; ++nChannel) - g_solo |= g_dynamic[nChannel].solo; - sprintf(g_oscpath, "/mixer/solo%d", MAX_CHANNELS - 1); - sendOscInt(g_oscpath, g_solo); +void togglePhase(uint8_t channel) { + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + if (g_channelStrips[channel]->phase) + g_channelStrips[channel]->phase = 0; + else + g_channelStrips[channel]->phase = 1; } -uint8_t getSolo(uint8_t channel) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - return g_dynamic[channel].solo; +void setSend(uint8_t channel, uint8_t send, float level) { + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL || send >= MAX_CHANNELS) + return; + g_channelStrips[channel]->send[send] = level; + sprintf(g_oscpath, "/mixer/channel/%d/send_%d", channel, send); + sendOscFloat(g_oscpath, level); } -void toggleMute(uint8_t channel) { - uint8_t mute; - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - mute = g_dynamic[channel].mute; - if (mute) - setMute(channel, 0); - else - setMute(channel, 1); +float getSend(uint8_t channel, uint8_t send) { + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL || send >= MAX_CHANNELS) + return 0.0f; + return g_channelStrips[channel]->send[send]; } -void togglePhase(uint8_t channel) { - uint8_t phase; - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - phase = g_dynamic[channel].phase; - if (phase) - setPhase(channel, 0); - else - setPhase(channel, 1); +void setNormalise(uint8_t channel, uint8_t enable) { +#ifndef MIXBUS + fprintf(stderr, "Normalisation not implemented in channel strips\n"); + return; +#endif + if (channel == 0 || channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + g_channelStrips[channel]->normalise = enable; + sprintf(g_oscpath, "/mixer/channel/%d/normalise", channel); + sendOscInt(g_oscpath, enable); +} + +uint8_t getNormalise(uint8_t channel) { +#ifndef MIXBUS + fprintf(stderr, "Normalisation not implemented in channel strips\n"); + return 0; +#endif + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return 0; + return g_channelStrips[channel]->normalise; } void setMono(uint8_t channel, uint8_t mono) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - g_dynamic[channel].mono = (mono != 0); - sprintf(g_oscpath, "/mixer/mono%d", channel); + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + g_channelStrips[channel]->mono = (mono != 0); + sprintf(g_oscpath, "/mixer/channel/%d/mono", channel); sendOscInt(g_oscpath, mono); } uint8_t getMono(uint8_t channel) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - return g_dynamic[channel].mono; + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return 0; + return g_channelStrips[channel]->mono; +} + +void toggleMono(uint8_t channel) { + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + if (g_channelStrips[channel]->mono) + g_channelStrips[channel]->mono = 0; + else + g_channelStrips[channel]->mono = 1; } void setMS(uint8_t channel, uint8_t enable) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - g_dynamic[channel].ms = enable != 0; + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + g_channelStrips[channel]->ms = enable != 0; + sprintf(g_oscpath, "/mixer/channel/%d/ms", channel); + sendOscInt(g_oscpath, enable); } uint8_t getMS(uint8_t channel) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; - return g_dynamic[channel].ms; + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return 0; + return g_channelStrips[channel]->ms; +} + +void toggleMS(uint8_t channel) { + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return; + if (g_channelStrips[channel]->ms) + g_channelStrips[channel]->ms = 0; + else + g_channelStrips[channel]->ms = 1; } void reset(uint8_t channel) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; setLevel(channel, 0.8); setBalance(channel, 0.0); setMute(channel, 0); setMono(channel, 0); setPhase(channel, 0); - setSolo(channel, 0); -} - -uint8_t isChannelRouted(uint8_t channel) { - if (channel >= MAX_CHANNELS) - return 0; - return g_dynamic[channel].inRouted; -} - -uint8_t isChannelOutRouted(uint8_t channel) { - if (channel >= MAX_CHANNELS) - return 0; - return g_dynamic[channel].outRouted; + for (uint8_t send = 0; send < MAX_CHANNELS; ++send) { + setSend(channel, send, 0.0); + setSendMode(channel, send, 0); + } } float getDpm(uint8_t channel, uint8_t leg) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return -200.0f; if (leg) - return convertToDBFS(g_dynamic[channel].dpmB); - return convertToDBFS(g_dynamic[channel].dpmA); + return convertToDBFS(g_channelStrips[channel]->dpmB); + return convertToDBFS(g_channelStrips[channel]->dpmA); } float getDpmHold(uint8_t channel, uint8_t leg) { - if (channel >= MAX_CHANNELS) - channel = MAX_CHANNELS - 1; + if (channel >= MAX_CHANNELS || g_channelStrips[channel] == NULL) + return -200.0f; if (leg) - return convertToDBFS(g_dynamic[channel].holdB); - return convertToDBFS(g_dynamic[channel].holdA); + return convertToDBFS(g_channelStrips[channel]->holdB); + return convertToDBFS(g_channelStrips[channel]->holdA); } -void getDpmStates(uint8_t start, uint8_t end, float* values) { - if (start > end) { - uint8_t tmp = start; - start = end; - end = tmp; +void updateDpmStates(dpm_struct* values, uint8_t count) { + if (count == 0 || count >= MAX_CHANNELS) + count = MAX_CHANNELS - 1; + for (uint8_t i = 0; i < count; ++i) { + if (i < g_lastStrip) { + values[i].a = getDpm(i, 0); + values[i].b = getDpm(i, 1); + values[i].aHold = getDpmHold(i, 0); + values[i].bHold = getDpmHold(i, 1); + values[i].mono = getMono(i); + } else { + memset(values + i, 0, sizeof(dpm_struct)); + } } - if (end > MAX_CHANNELS) - end = MAX_CHANNELS; - if (start > MAX_CHANNELS) - start = MAX_CHANNELS; - uint8_t count = end - start + 1; - while (count--) { - *(values++) = getDpm(start, 0); - *(values++) = getDpm(start, 1); - *(values++) = getDpmHold(start, 0); - *(values++) = getDpmHold(start, 1); - *(values++) = getMono(start); - ++start; +} + +void enableDpm(uint8_t enable) { + struct channel_strip* pChannel; + for (uint8_t chan = 0; chan < MAX_CHANNELS; ++chan) { + if (g_channelStrips[chan] == NULL) + continue; +#ifdef MIXBUS + if (chan == 0) + g_channelStrips[chan]->enable_dpm = 1; + else if (chan == 1) + g_channelStrips[chan]->enable_dpm = 0; + else +#endif + g_channelStrips[chan]->enable_dpm = enable; + // Silence disabled DPMs + if (!g_channelStrips[chan]->enable_dpm) { + g_channelStrips[chan]->dpmA = 0.0f; + g_channelStrips[chan]->dpmB = 0.0f; + g_channelStrips[chan]->holdA = 0.0f; + g_channelStrips[chan]->holdB = 0.0f; + } + } +} + +int8_t addStrip() { + uint8_t chan; + for (chan = 0; chan < MAX_CHANNELS; ++chan) { + if (g_channelStrips[chan]) + continue; + struct channel_strip* strip = malloc(sizeof(struct channel_strip)); + if (strip == NULL) { + fprintf(stderr, "Failed to allocate memory for channel strip.\n"); + return -1; + } + char name[11]; + sprintf(name, "input_%02da", chan); + if (!(strip->inPortA = jack_port_register(g_jackClient, name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0))) { + fprintf(stderr, "libzynmixer: Cannot register %s\n", name); + free(strip); + return -1; + } + sprintf(name, "input_%02db", chan); + if (!(strip->inPortB = jack_port_register(g_jackClient, name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0))) { + fprintf(stderr, "libzynmixer: Cannot register %s\n", name); + jack_port_unregister(g_jackClient, strip->inPortA); + free(strip); + return -1; + } + sprintf(name, "output_%02da", chan); + if (!(strip->outPortA = jack_port_register(g_jackClient, name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0))) { + fprintf(stderr, "libzynmixer: Cannot register %s\n", name); + jack_port_unregister(g_jackClient, strip->inPortA); + jack_port_unregister(g_jackClient, strip->inPortB); + free(strip); + return -1; + } + sprintf(name, "output_%02db", chan); + if (!(strip->outPortB = jack_port_register(g_jackClient, name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0))) { + fprintf(stderr, "libzynmixer: Cannot register %s\n", name); + jack_port_unregister(g_jackClient, strip->inPortA); + jack_port_unregister(g_jackClient, strip->inPortB); + jack_port_unregister(g_jackClient, strip->outPortA); + free(strip); + return -1; + } + strip->level = 0.0; + strip->reqlevel = 0.8; + strip->balance = 0.0; + strip->reqbalance = 0.0; + strip->mute = 0; + strip->mono = 0; + strip->solo = 0; + strip->ms = 0; + strip->phase = 0; + strip->normalise = 0; + strip->inRouted = 0; + strip->outRouted = 0; + strip->enable_dpm = 1; + for (uint8_t send = 0; send < MAX_CHANNELS; ++send) { + strip->send[send] = 0.0; + strip->sendMode[send] = 0; + } + strip->dpmA = strip->holdA = 0.0f; + strip->dpmB = strip->holdB = 0.0f; + strip->dpmAlast = 100.0f; + strip->dpmBlast = 100.0f; + strip->holdAlast = 100.0f; + strip->holdBlast = 100.0f; + pthread_mutex_lock(&mutex); + g_channelStrips[chan] = strip; + pthread_mutex_unlock(&mutex); + + if (chan >= g_lastStrip) + g_lastStrip = chan + 1; + return chan; } + return -1; } -void enableDpm(uint8_t start, uint8_t end, uint8_t enable) { - struct dynamic* pChannel; - if (start > end) { - uint8_t tmp = start; - start = end; - end = tmp; +int8_t removeStrip(uint8_t chan) { +#ifdef MIXBUS + if (chan == 0) { + fprintf(stderr, "Cannot remove main mixbus\n"); + return -1; } - if (start >= MAX_CHANNELS) - start = MAX_CHANNELS - 1; - if (end >= MAX_CHANNELS) - end = MAX_CHANNELS - 1; - for (uint8_t channel = start; channel <= end; ++channel) { - pChannel = &(g_dynamic[channel]); - pChannel->enable_dpm = enable; - if (enable == 0) { - pChannel->dpmA = 0; - pChannel->dpmB = 0; - pChannel->holdA = 0; - pChannel->holdB = 0; +#endif + if (chan >= MAX_CHANNELS || g_channelStrips[chan] == NULL) + return -1; + struct channel_strip* pstrip = g_channelStrips[chan]; + pthread_mutex_lock(&mutex); + g_channelStrips[chan] = NULL; + pthread_mutex_unlock(&mutex); + jack_port_unregister(g_jackClient, pstrip->inPortA); + jack_port_unregister(g_jackClient, pstrip->inPortB); + jack_port_unregister(g_jackClient, pstrip->outPortA); + jack_port_unregister(g_jackClient, pstrip->outPortB); + free(pstrip); + for (uint8_t g_lastStrip = MAX_CHANNELS - 1; g_lastStrip > 0; --g_lastStrip) { + if (g_channelStrips[g_lastStrip]) + break; + } + return chan; +} + +int8_t addSend() { +#ifdef MIXBUS + fprintf(stderr, "Effects sends not implemented in mixbus\n"); +#else + for (uint8_t send = 0; send < MAX_CHANNELS; ++send) { + if (g_fxSends[send] == NULL) { + struct fx_send* psend = malloc(sizeof(struct fx_send)); + if (!psend) { + fprintf(stderr, "Failed to allocated memory for effect send %d\n", send); + return -1; + } + char name[11]; + sprintf(name, "send_%02da", send + 2); + if (!(psend->outPortA = jack_port_register(g_jackClient, name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0))) { + free(psend); + psend = NULL; + fprintf(stderr, "libzynmixer: Cannot register %s\n", name); + return -1; + } + sprintf(name, "send_%02db", send + 2); + if (!(psend->outPortB = jack_port_register(g_jackClient, name, JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0))) { + jack_port_unregister(g_jackClient, psend->outPortA); + free(psend); + fprintf(stderr, "libzynmixer: Cannot register %s\n", name); + return -1; + } + psend->bufferA = jack_port_get_buffer(psend->outPortA, g_buffersize); + psend->bufferB = jack_port_get_buffer(psend->outPortB, g_buffersize); + psend->level = 1.0; + pthread_mutex_lock(&mutex); + g_fxSends[send] = psend; + ++g_sendCount; + pthread_mutex_unlock(&mutex); + if (send >= g_lastSend) + g_lastSend = send + 1; + return send + 1; } } + fprintf(stderr, "Exceeded maximum quantity of sends (%d).\n", MAX_CHANNELS); +#endif + return -1; } +uint8_t removeSend(uint8_t send) { +#ifdef MIXBUS + fprintf(stderr, "Effects sends not implemented in mixbus\n"); + return 1; +#else + send -= 2; // We expose sends at 2-based so need to decrement to access array + if (send >= MAX_CHANNELS || g_fxSends[send] == NULL) + return 1; + struct fx_send* pstrip = g_fxSends[send]; + pthread_mutex_lock(&mutex); + g_fxSends[send] = NULL; + --g_sendCount; + pthread_mutex_unlock(&mutex); + jack_port_unregister(g_jackClient, pstrip->outPortA); + jack_port_unregister(g_jackClient, pstrip->outPortB); + free(pstrip); + for (g_lastSend = MAX_CHANNELS - 1; g_lastSend > 0; --g_lastSend) { + if (g_fxSends[g_lastSend]) + break; + } + return 0; +#endif +} + + +uint8_t getSendCount() { + return g_sendCount; +} + +uint8_t getMaxChannels() { return MAX_CHANNELS; } + +uint8_t getLastChannel() { return g_lastStrip; } + int addOscClient(const char* client) { for (uint8_t i = 0; i < MAX_OSC_CLIENTS; ++i) { if (g_oscClient[i].sin_addr.s_addr != 0) @@ -700,17 +1081,24 @@ int addOscClient(const char* client) { return -1; } fprintf(stderr, "libzynmixer: Added OSC client %d: %s\n", i, client); - for (int nChannel = 0; nChannel < MAX_CHANNELS; ++nChannel) { - setBalance(nChannel, getBalance(nChannel)); - setLevel(nChannel, getLevel(nChannel)); - setMono(nChannel, getMono(nChannel)); - setMute(nChannel, getMute(nChannel)); - setPhase(nChannel, getPhase(nChannel)); - setSolo(nChannel, getSolo(nChannel)); - g_dynamic_last[nChannel].dpmA = 100.0; - g_dynamic_last[nChannel].dpmB = 100.0; - g_dynamic_last[nChannel].holdA = 100.0; - g_dynamic_last[nChannel].holdB = 100.0; + for (int chan = 0; chan < MAX_CHANNELS; ++chan) { + setBalance(chan, getBalance(chan)); + setLevel(chan, getLevel(chan)); + setMono(chan, getMono(chan)); + setMute(chan, getMute(chan)); + setPhase(chan, getPhase(chan)); +#ifndef MIXBUS + for (uint8_t send = 0; send < MAX_CHANNELS; ++send) { + if (g_fxSends[send]) { + setSend(chan, send, getSend(chan, send)); + setSendMode(chan, send, getSendMode(chan, send)); + } + } +#endif + g_channelStrips[chan]->dpmAlast = 100.0f; + g_channelStrips[chan]->dpmBlast = 100.0f; + g_channelStrips[chan]->holdAlast = 100.0f; + g_channelStrips[chan]->holdBlast = 100.0f; } g_bOsc = 1; return i; @@ -733,5 +1121,3 @@ void removeOscClient(const char* client) { g_bOsc = 1; } } - -uint8_t getMaxChannels() { return MAX_CHANNELS; } diff --git a/zynlibs/zynmixer/mixer.h b/zynlibs/zynmixer/mixer.h index a8f0cad31..c7225cc91 100644 --- a/zynlibs/zynmixer/mixer.h +++ b/zynlibs/zynmixer/mixer.h @@ -26,6 +26,14 @@ #include #include //provides fixed width integer types +typedef struct { + float a; + float b; + float aHold; + float bHold; + uint8_t mono; +} dpm_struct; + //----------------------------------------------------------------------------- // Library Initialization //----------------------------------------------------------------------------- @@ -33,7 +41,7 @@ /** @brief Initialises library * @retval int 1 on success, 0 on fail */ -int init(); +int init() __attribute__((constructor)); /** @brief Destroy library */ @@ -42,96 +50,149 @@ void end(); /** @brief Set channel level * @param channel Index of channel * @param level Channel level (0..1) - * @note Channel > MAX_CHANNELS will set master fader level */ void setLevel(uint8_t channel, float level); /** @brief Get channel level * @param channel Index of channel * @retval float Channel level (0..1) - * @note Channel > MAX_CHANNELS will retrived master fader level */ float getLevel(uint8_t channel); /** @brief Set channel balance * @param channel Index of channel * @param pan Channel pan (-1..1) - * @note Channel > MAX_CHANNELS will set master balance */ void setBalance(uint8_t channel, float pan); /** @brief Get channel balance * @param channel Index of channel * @retval float Channel pan (-1..1) - * @note Channel > MAX_CHANNELS will retrived master balance */ float getBalance(uint8_t channel); -/** @brief Set mute state of channel +/** @brief Set channel mute state * @param channel Index of channel * @param mute Mute status (0: Unmute, 1: Mute) */ void setMute(uint8_t channel, uint8_t mute); -/** @brief Get mute state of channel +/** @brief Get channel mute state * @param channel Index of channel * @retval uint8_t Mute status (0: Unmute, 1: Mute) */ uint8_t getMute(uint8_t channel); -/** @brief Set solo state of channel +/** @brief Toggles channel mute + * @param channel Index of channel + */ +void toggleMute(uint8_t channel); + +/** @brief Set channel solo state * @param channel Index of channel - * @param solo Solostatus (0: Normal, 1: Solo) + * @param solo Solo status (0: Unsolo, 1: Solo) */ void setSolo(uint8_t channel, uint8_t solo); -/** @brief Get solo state of channel +/** @brief Get channel solo state * @param channel Index of channel - * @retval uint8_t Solo status (0: Normal, 1: solo) + * @retval uint8_t Solo status (0: Unsolo, 1: Solo) */ uint8_t getSolo(uint8_t channel); -/** @brief Toggles mute of a channel - * @param channel Index of channel +/** @brief Toggles channel solo + @param channel Index of channel */ -void toggleMute(uint8_t channel); +void toggleSolo(uint8_t channel); + +/** @brief Clear solo from all channel +*/ +void clearSolo(); -/** @brief Set mono state of channel +/** @brief Get global solo + @retval uint8_t Quantity of channels with solo asserted +*/ +uint8_t getGlobalSolo(); + +/** @brief Set channel mono state * @param channel Index of channel * @param mono (0: Stereo, 1: Mono) */ void setMono(uint8_t channel, uint8_t mono); -/** @brief Get mono state of channel +/** @brief Get channel mono state * @param channel Index of channel * @retval uint8_t Channel mono state (0: Stereo, 1: mono) */ uint8_t getMono(uint8_t channel); -/** @brief Enable MS decode mode +/** @brief Toggles channel mono + * @param channel Index of channel + */ +void toggleMono(uint8_t channel); + +/** @brief Set channel MS decode mode * @param channel Index of channel * @param enable (0: Stereo, 1: MS decode) */ void setMS(uint8_t channel, uint8_t enable); -/** @brief Get MS decode mode +/** @brief Get channel MS decode mode * @param channel Index of channel * @retval uint8_t MS decode mode (0: Stereo, 1: MS decode) */ uint8_t getMS(uint8_t channel); -/** @brief Set phase state of channel +/** @brief Toggles channel M+S + * @param channel Index of channel + */ +void toggleMS(uint8_t channel); + +/** @brief Set channel phase state * @param channel Index of channel * @param phase (0: in phase, 1: phase reversed) */ void setPhase(uint8_t channel, uint8_t phase); -/** @brief Get phase state of channel +/** @brief Get channel phase state * @param channel Index of channel * @retval uint8_t Channel phase state (0: in phase, 1: phase reversed) */ uint8_t getPhase(uint8_t channel); +/** @brief Toggles channel phase + * @param channel Index of channel + */ +void togglePhase(uint8_t channel); + +/** @brief Set channel send mode + * @param channel Index of channel + * @param send Index of send + * @param mode (0: post-fader, 1: pre-fader) + */ +void setSendMode(uint8_t channel, uint8_t send, uint8_t mode); + +/** @brief Get channel send mode + * @param channel Index of channel + * @param send Index of send + * @retval uint8_t Channel send mode (0: pre-fader, 1: post-fader, 2: post-pan) + */ +uint8_t getSendMode(uint8_t channel, uint8_t send); + +/** @brief Set channel fx send level + * @param channel Index of channel + * @param send Index of fx send + * @param level Channel level (0..1) + */ +void setSend(uint8_t channel, uint8_t send, float level); + +/** @brief Get channel fx send level + * @param channel Index of channel + * @param send Index of fx send + * @retval float Channel send level + */ +float getSend(uint8_t channel, uint8_t send); + /** @brief Set internal normalisation of channel * @param channel Index of channel * @param enable 1 to enable internal normalisation when channel direct output not routed @@ -142,25 +203,13 @@ void setNormalise(uint8_t channel, uint8_t enable); * @param channel Index of channel * @retval uint8_t 1 if channel normalised */ -uint8_t getNormalise(uint8_t channel, uint8_t enable); +uint8_t getNormalise(uint8_t channel); /** @brief Reset a channel to default settings * @param channel Index of channel */ void reset(uint8_t channel); -/** @brief Check if channel has source routed - * @param channel Index of channel - * @retval uint8_t 1 if channel has source routed. 0 if no source routed to channel. - */ -uint8_t isChannelRouted(uint8_t channel); - -/** @brief Check if channel has output routed - * @param channel Index of channel - * @retval uint8_t 1 if channel has output routed. 0 if not routed. - */ -uint8_t isChannelOutRouted(uint8_t channel); - /** @brief Get DPM level * @param channel Index of channel * @param leg 0 for A leg (left), 1 for B leg (right) @@ -175,20 +224,55 @@ float getDpm(uint8_t channel, uint8_t leg); */ float getDpmHold(uint8_t channel, uint8_t leg); -/** @brief Get DPM state for a set of channels - * @param start Index of the first channel - * @param end Index of the last channel - * @param values Pointer to array of floats to hold DPM, hold, and mono status for each channel +/** @brief Update DPM states + * @param values Pointer to array of structure to hold DPM, hold, and mono status for each channel + * @param count Quantity of channels to update or 0 for all */ -void getDpmStates(uint8_t start, uint8_t end, float* values); +void updateDpmStates(dpm_struct* values, uint8_t count); /** @brief Enable / disable peak programme metering - * @param start Index of first channel - * @param end Index of last channel - * @param enable 1 to enable, 0 to disable - * @note DPM increase CPU processing so may be disabled if this causes issues (like xruns) + * @param enable 1 to enable, 0 to disable + * @note DPM increase CPU processing so may be disabled if this causes issues (like xruns) + * @note Main mixbus is always enabled + */ +void enableDpm(uint8_t enable); + +/** Add a channel strip + * @retval int8_t Index of channel strip or -1 on failure + */ +int8_t addStrip(); + +/** @brief Remove a channel strip + * @param chan Index of channel strip to remove + * @retval int8_t Index of strip removed or -1 on failure + */ +int8_t removeStrip(uint8_t chan); + +/** Add an effect send + * @retval int8_t Index of send or -1 on failure + */ +int8_t addSend(); + +/** @brief Remove an effect send + * @param send Index of send to remove + * @retval int8_t 0 on success, 1 on failure */ -void enableDpm(uint8_t start, uint8_t end, uint8_t enable); +uint8_t removeSend(uint8_t send); + +/** @brief Get maximum quantity of channels + * @retval size_t Maximum quantity of channels + */ +uint8_t getMaxChannels(); + +/** @brief Get index of highest numbered channel + * @retval size_t Index of last channel + */ +uint8_t getLastChannel(); + +/** @brief Get quantity of effect sends + * @retval uint8_t Quantity of effect sends + */ +uint8_t getSendCount(); /** @brief Adds client to list of registered OSC clients * @param client IP address of client @@ -201,8 +285,3 @@ int addOscClient(const char* client); * @param client IP address of client */ void removeOscClient(const char* client); - -/** @brief Get maximum quantity of channels - * @retval size_t Maximum quantity of channels - */ -uint8_t getMaxChannels(); diff --git a/zynlibs/zynmixer/zynmixer.py b/zynlibs/zynmixer/zynmixer.py new file mode 100644 index 000000000..475fc213e --- /dev/null +++ b/zynlibs/zynmixer/zynmixer.py @@ -0,0 +1,716 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# ******************************************************************** +# ZYNTHIAN PROJECT: Zynmixer Python Wrapper +# +# A Python wrapper for zynmixer library +# +# Copyright (C) 2019-2026 Brian Walton +# +# ******************************************************************** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# For a full copy of the GNU General Public License see the LICENSE.txt file. +# +# ******************************************************************** + +import ctypes +from zyngine.zynthian_signal_manager import zynsigman + +# ------------------------------------------------------------------------------- +# Zynmixer Library Wrapper and processor +# ------------------------------------------------------------------------------- + +# Subsignals are defined inside each module. Here we define audio_mixer subsignals: +SS_ZYNMIXER_SET_VALUE = 1 + +class DPM(ctypes.Structure): + _fields_ = [ + ("a", ctypes.c_float), + ("b", ctypes.c_float), + ("a_hold", ctypes.c_float), + ("b_hold", ctypes.c_float), + ("mono", ctypes.c_uint8) + ] + +class ZynMixer(): + """ + A class representing an instance of a zynmixer, audio mixer library. + """ + + # Function to initialize library + def __init__(self, is_mixbus=False): + self.mixbus = is_mixbus + if is_mixbus: + self.lib_zynmixer = ctypes.cdll.LoadLibrary( + f"/zynthian/zynthian-ui/zynlibs/zynmixer/build/libzynmixer_mixbus.so") + else: + self.lib_zynmixer = ctypes.cdll.LoadLibrary( + f"/zynthian/zynthian-ui/zynlibs/zynmixer/build/libzynmixer.so") + + self.lib_zynmixer.addStrip.restype = ctypes.c_int8 + self.lib_zynmixer.removeStrip.argtypes = [ctypes.c_uint8] + self.lib_zynmixer.removeStrip.restype = ctypes.c_int8 + + self.lib_zynmixer.setLevel.argtypes = [ctypes.c_uint8, ctypes.c_float] + self.lib_zynmixer.getLevel.argtypes = [ctypes.c_uint8] + self.lib_zynmixer.getLevel.restype = ctypes.c_float + + self.lib_zynmixer.setBalance.argtypes = [ + ctypes.c_uint8, ctypes.c_float] + self.lib_zynmixer.getBalance.argtypes = [ctypes.c_uint8] + self.lib_zynmixer.getBalance.restype = ctypes.c_float + + self.lib_zynmixer.setMute.argtypes = [ctypes.c_uint8, ctypes.c_uint8] + self.lib_zynmixer.toggleMute.argtypes = [ctypes.c_uint8] + self.lib_zynmixer.getMute.argtypes = [ctypes.c_uint8] + self.lib_zynmixer.getMute.restype = ctypes.c_uint8 + + self.lib_zynmixer.setMS.argtypes = [ctypes.c_uint8, ctypes.c_uint8] + self.lib_zynmixer.getMS.argtypes = [ctypes.c_uint8] + self.lib_zynmixer.getMS.restypes = ctypes.c_uint8 + + self.lib_zynmixer.setMono.argtypes = [ctypes.c_uint8, ctypes.c_uint8] + self.lib_zynmixer.getMono.argtypes = [ctypes.c_uint8] + self.lib_zynmixer.getMono.restype = ctypes.c_uint8 + + self.lib_zynmixer.setPhase.argtypes = [ctypes.c_uint8, ctypes.c_uint8] + self.lib_zynmixer.getPhase.argtypes = [ctypes.c_uint8] + self.lib_zynmixer.getPhase.restype = ctypes.c_uint8 + + self.lib_zynmixer.setSendMode.argtypes = [ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8] + self.lib_zynmixer.getSendMode.argtypes = [ctypes.c_uint8, ctypes.c_uint8] + self.lib_zynmixer.getSendMode.restype = ctypes.c_uint8 + + self.lib_zynmixer.addSend.restype = ctypes.c_int + + self.lib_zynmixer.removeSend.argtypes = [ctypes.c_uint8] + self.lib_zynmixer.removeSend.restype = ctypes.c_uint8 + + self.lib_zynmixer.setSend.argtypes = [ctypes.c_uint8, ctypes.c_uint8, ctypes.c_float] + self.lib_zynmixer.getSend.argtypes = [ctypes.c_uint8, ctypes.c_uint8] + self.lib_zynmixer.getSend.restype = ctypes.c_float + + self.lib_zynmixer.setNormalise.argtypes = [ + ctypes.c_uint8] + self.lib_zynmixer.getNormalise.argtypes = [ctypes.c_uint8] + self.lib_zynmixer.getNormalise.restype = ctypes.c_uint8 + + self.lib_zynmixer.reset.argtypes = [ctypes.c_uint8] + + self.lib_zynmixer.getDpm.argtypes = [ctypes.c_uint8, ctypes.c_uint8] + self.lib_zynmixer.getDpm.restype = ctypes.c_float + + self.lib_zynmixer.getDpmHold.argtypes = [ + ctypes.c_uint8, ctypes.c_uint8] + self.lib_zynmixer.getDpmHold.restype = ctypes.c_float + + self.lib_zynmixer.updateDpmStates.argtypes = [ctypes.POINTER(DPM), ctypes.c_uint8] + + self.lib_zynmixer.getMaxChannels.restype = ctypes.c_uint8 + + self.MAX_NUM_CHANNELS = self.lib_zynmixer.getMaxChannels() + self.dpm = (DPM * self.MAX_NUM_CHANNELS)() + + def add_strip(self): + """ + Adds a mixer strip to the mixer + + Returns + ------- + int + Index of strip or -1 on failure + + """ + + return self.lib_zynmixer.addStrip() + + def remove_strip(self, chan): + """ + Removes a mixer channel strip from the mixer + + Parameters + ---------- + chan : int + Index of the mixer channel strip to remove + + Returns + ------- + int + Index of strip or -1 on failure + """ + + return self.lib_zynmixer.removeStrip(chan) + + def add_send(self): + """ + Adds an effect send to the mixer + + Returns + ------- + int + Index of send or -1 on failure + + """ + + return self.lib_zynmixer.addSend() + + def remove_send(self, send): + """ + Removes an effect send from the mixer + + Parameters + ---------- + send : int + Index of the effect send to remove + + Returns + ------- + int + Index of send or -1 on failure + """ + + return self.lib_zynmixer.removeSend(send) + + def get_send_count(self): + """ + Get the quantity of effect sends + + Returns + ------- + int + Qauntity of effect sends + """ + + return self.lib_zynmixer.getSendCount() + + def set_level(self, channel, level): + """ + Sets the fader level of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + level : float + Value of level (0..1.0) + """ + + if channel is None: + return + self.lib_zynmixer.setLevel(channel, ctypes.c_float(level)) + zynsigman.send(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, + chan=channel, symbol="level", value=level, mixbus=self.mixbus) + + def get_level(self, channel): + """ + Gets the fader level of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + + Returns + ------- + float + Fader level (0..1.0) + """ + + if channel is None: + return + return self.lib_zynmixer.getLevel(channel) + + def set_balance(self, channel, balance): + """ + Sets the balance of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + balance : float + Value of balance (-1.0..1.0) + """ + + if channel is None: + return + self.lib_zynmixer.setBalance(channel, balance) + zynsigman.send(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, + chan=channel, symbol="balance", value=balance, mixbus=self.mixbus) + + def get_balance(self, channel): + """ + Gets the balance of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + + Returns + ------- + float + Balance level (-1.0..1.0) + """ + if channel is None: + return + return self.lib_zynmixer.getBalance(channel) + + def set_mute(self, channel, mute): + """ + Sets the mute of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + mute : bool + True to mute, False to unmute + """ + + if channel is None: + return + self.lib_zynmixer.setMute(channel, mute) + zynsigman.send(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, + chan=channel, symbol="mute", value=mute, mixbus=self.mixbus) + + # Function to get mute for a channel + # channel: Index of channel + # returns: Mute state (True if muted) + def get_mute(self, channel): + """ + Gets the mute state of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + + Returns + ------- + bool + True if mute enabled, False if disabled + """ + + if channel is None: + return + return self.lib_zynmixer.getMute(channel) + + def toggle_mute(self, channel): + """ + Toggle the mute state of a mixer strip + + Parameters + ---------- + channel : int + Index of of the mixer strip + """ + + self.lib_zynmixer.toggleMute(channel) + + def set_solo(self, channel, solo): + """ + Sets the solo of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + solo : bool + True to solo, False to unsolo + """ + + if channel is None: + return + self.lib_zynmixer.setSolo(channel, solo) + zynsigman.send(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, + chan=channel, symbol="solo", value=solo, mixbus=self.mixbus) + + # Function to get solo for a channel + # channel: Index of channel + # returns: Solo state (True if solod) + def get_solo(self, channel): + """ + Gets the solo state of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + + Returns + ------- + bool + True if solo enabled, False if disabled + """ + + if channel is None: + return + return self.lib_zynmixer.getSolo(channel) + + def toggle_solo(self, channel): + """ + Toggle the solo state of a mixer strip + + Parameters + ---------- + channel : int + Index of of the mixer strip + """ + + self.lib_zynmixer.toggleSolo(channel) + + def clear_solo(self): + self.lib_zynmixer.clearSolo() + + def get_global_solo(self): + return self.lib_zynmixer.getGlobalSolo() + + def set_phase(self, channel, phase): + """ + Sets the phase reverse of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + phase : bool + True to phase reverse, False for normal + """ + + if channel is None: + return + self.lib_zynmixer.setPhase(channel, phase) + zynsigman.send(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, + chan=channel, symbol="phase", value=phase, mixbus=self.mixbus) + + def get_phase(self, channel): + """ + Gets the phase reverse state of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + + Returns + ------- + bool + True if phase reverse enabled, False if disabled + """ + + if channel is None: + return + return self.lib_zynmixer.getPhase(channel) + + def toggle_phase(self, channel): + """ + Toggle the phase reverse state of a mixer strip + + Parameters + ---------- + channel : int + Index of of the mixer strip + """ + + if channel is None: + return + self.lib_zynmixer.togglePhase(channel) + + def set_record(self, channel, record): + # State handled entirely by zctrl + zynsigman.send(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, + chan=channel, symbol="record", value=record, mixbus=self.mixbus) + + def set_send_mode(self, channel, send, mode): + """ + Sets the effect send mode of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + send : int + Index of the send + mode : int + 0: post fader, 1: pre fader + """ + + if channel is None or 0 >= mode > 1: + return + self.lib_zynmixer.setSendMode(channel, send, mode) + zynsigman.send(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, + chan=channel, symbol="send_mode", value=mode, mixbus=self.mixbus) + + def get_send_mode(self, channel, send): + """ + Gets the effect send mode of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + send : int + Index of the send + + Returns + ------- + int + 0: post fader, 1: pre fader + """ + + if channel is None: + return + return self.lib_zynmixer.getSendMode(channel, send) + + def set_mono(self, channel, mono): + """ + Sets the mono state of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + mono : bool + True to for mono, False for stereo + """ + + if channel is None: + return + self.lib_zynmixer.setMono(channel, mono) + zynsigman.send(zynsigman.S_MIXER, SS_ZYNMIXER_SET_VALUE, + chan=channel, symbol="mono", value=mono, mixbus=self.mixbus) + + def get_mono(self, channel): + """ + Gets the mono state of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + + Returns + ------- + bool + True if mono, False if stereo + """ + + if channel is None: + return + return self.lib_zynmixer.getMono(channel) + + def get_all_monos(self): + """ + Gets the mono state of all mixer strips + + Parameters + ---------- + channel : int + Index of the mixer strip + + Returns + ------- + list + A list of bools indicating the mono state of each strip + """ + + monos = (ctypes.c_bool * (self.MAX_NUM_CHANNELS))() + self.lib_zynmixer.getAllMono(monos) + result = [] + for i in monos: + result.append(i) + return result + + def toggle_mono(self, channel): + """ + Toggle the mono state of a mixer strip + + Parameters + ---------- + channel : int + Index of of the mixer strip + """ + + if channel is None: + return + if self.get_mono(channel): + self.set_mono(channel, False) + else: + self.set_mono(channel, True) + + def set_ms(self, channel, enable): + """ + Sets the M+S state of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + enable : bool + True to enable M+S, False to disable + """ + + if channel is None: + return + self.lib_zynmixer.setMS(channel, enable) + + def get_ms(self, channel): + """ + Gets the M+S state of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + + Returns + ------- + bool + True if M+S enabled, False if disabled + """ + + if channel is None: + return + return self.lib_zynmixer.getMS(channel) == 1 + + def toggle_ms(self, channel): + """ + Toggle the M+S state of a mixer strip + + Parameters + ---------- + channel : int + Index of of the mixer strip + """ + + if channel is None: + return + self.set_ms(channel, not self.get_ms(channel)) + + def set_send_level(self, channel, send, level): + """ + Sets an effect send level of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + send : int + Index of the effect send + level : float + Value of level (0..1.0) + """ + + if channel is None or send is None: + return + self.lib_zynmixer.setSend(channel, send, level) + + def get_send_level(self, channel, send): + """ + Gets a send level of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + send : int + Index of the send + + Returns + ------- + float + Value of the send level (0..1.0) + """ + + if channel is None or send is None: + return + return self.lib_zynmixer.getSend(channel, send) + + def normalise(self, channel, enable): + """ + Sets the internal normalisation to strip 0 of a mixer strip (only on buses) + + Parameters + ---------- + channel : int + Index of the mixer strip + enable : bool + True to internally route strip to strip 0 (main mixbus), False to disable this normalisation + """ + + if channel is None: + return + self.lib_zynmixer.setNormalise(channel, enable) + + def is_normalised(self, channel): + """ + Gets the internal normalised routig state of a mixer strip + + Parameters + ---------- + channel : int + Index of the mixer strip + + Returns + ------- + bool + True if normalised routing to strip 0 (main mixbus), False if disabled + """ + + if channel is None: + return False + return self.lib_zynmixer.getNormalise(channel) == 1 + + def update_dpm_states(self, count=0): + """ + Updates peak programme level state of a range of mixer strips + + Parameters + ---------- + count : int + Quantity of mixer strips (default: 0 for all) + """ + + self.lib_zynmixer.updateDpmStates(self.dpm, count) + + def enable_dpm(self, enable): + """ + Enable or disable peak programme meter + + Parameters + ---------- + enable : bool + True to enable DPM, False to disable + Note: Main mixbus is always enabled + """ + + self.lib_zynmixer.enableDpm(int(enable)) + + # Function to add OSC client registration + # client: IP address of OSC client + def add_osc_client(self, client): + return self.lib_zynmixer.addOscClient(ctypes.c_char_p(client.encode('utf-8'))) + + # Function to remove OSC client registration + # client: IP address of OSC client + def remove_osc_client(self, client): + self.lib_zynmixer.removeOscClient( + ctypes.c_char_p(client.encode('utf-8'))) + + def reset(self): + for channel in range(self.MAX_NUM_CHANNELS): + self.lib_zynmixer.reset(channel, False) + self.lib_zynmixer.reset(channel, True) + +# ------------------------------------------------------------------------------- diff --git a/zynlibs/zynseq/constants.h b/zynlibs/zynseq/constants.h index dbdb96aa8..fe5cc914e 100644 --- a/zynlibs/zynseq/constants.h +++ b/zynlibs/zynseq/constants.h @@ -28,50 +28,94 @@ #pragma once #include -#define DEFAULT_TEMPO 120 // March time (120 BPM) +#define DEFAULT_TEMPO 120.0 // March time (120 BPM) +#define DEFAULT_BPB 4 // Default time signature (Beats Per Bar) +#define PHRASE_CHANNEL 32 // Phrase launcher channel -// Play mode -#define DISABLED 0 // Does not start, stops immediately -#define ONESHOT 1 // Play once, stops immediately - Should it reset to zero when stopped? -#define LOOP 2 // Loop whole sequence restarting immediately at end of sequence, stop at end of sequence -#define ONESHOTALL 3 // Play once all way to end, stop at end of sequence -#define LOOPALL 4 // Play whole sequence then start again at next sync point, stop at end of sequence -#define ONESHOTSYNC 5 // Play once until sync point truncating if necessary, stop at sync point -#define LOOPSYNC 6 // Play sequence looping at sync point, truncating if necessary, stop at sync point -#define LASTPLAYMODE 6 +// Play modes START & END are OR'd to provide mode +// Bits 0..1 Stop mode +#define MODE_END_END 0 // Stop at end of sequence +#define MODE_END_SYNC 1 // Stop at next sync +#define MODE_END_IMMEDIATE 2 // Stop immediately +// Bit 2 Start mode +#define MODE_START_SYNC 0 // Start at next sync +#define MODE_START_IMMEDIATE 4 // Start immediately -// Play status +// Clock trigger flags +#define CLOCK_TRIG_MIDI 1 // Clock has triggered a MIDI event +#define CLOCK_TRIG_TEMPO 2 // Clock has triggered a tempo change +#define CLOCK_TRIG_TIMESIG 4 // Clock has triggered a time signature change +#define CLOCK_TRIG_SEQEND 8 // Clock has triggered a sequence end +#define CLOCK_TRIG_PHRASE 16 // Clock has triggered a phrase change + +// Clock rates +#define PPQN_INTERNAL 1920 // Quantity of sequencer clock pulses in each beat +#define PPQN_MIDI 24 // Quantity of MIDI clock pulses in each beat + +// Play status (bit 0 = playing) #define STOPPED 0 // Sequence is stopped #define PLAYING 1 // Sequence is playing -#define STOPPING 2 // Sequence is playing waiting to stop -#define STARTING 3 // Sequence is paused waiting to start -#define RESTARTING 4 // Sequence is restarting after hitting end of loop +#define STARTING 2 // Sequence is paused waiting to start +#define STOPPING 3 // Sequence is playing waiting to stop +#define FORCED_STOP 4 // Sequence is stopped immediately #define STOPPING_SYNC 5 // Sequence is playing waiting to stop at next sync point -#define LASTPLAYSTATUS 5 +#define CHILD_PLAYING 6 // Child (of phrase launcher) sequence is playing +#define CHILD_STOPPING 8 // Child (of phrase launcher) sequence is stopping + +// Metronome modes +enum METRO_MODES { + METRO_MODE_OFF, // Disable metronome + METRO_MODE_TRANSPORT, // Play metronome only when transport running + METRO_MODE_ON, // Play metronome + METRO_MODE_INTRO, // Play metronome only when trasport stopped + METRO_MODE_NO_PEEP, // Play metronome without accent + METRO_MODE_SILENT, // Play transport but don't play metronome + METRO_MODE_LAST // Dummy - used for range check +}; + +// Jack transport modes +enum JTRANS_MODES { + JTRANS_MODE_OFF, // Disable jack transport + JTRANS_MODE_LOCK, // Lock jack transport to internal transport + JTRANS_MODE_TRIG, // Trigger jack transport start from internal transport start, don't auto stop + JTRANS_MODE_ON, // Enable jack transport running continually + JTRANS_MODE_LAST // Dummy - usef for range check +}; + +// Local transport flags +#define TRANSPORT_CLIENT_ZYNSEQ 1 +#define TRANSPORT_CLIENT_METRO 2 + +// Follow action +enum FOLLOW_ACTION { + FOLLOW_ACTION_NONE, + FOLLOW_ACTION_RELATIVE, + FOLLOW_ACTION_ABSOLUTE +}; // MIDI commands -#define MIDI_NOTE_OFF 0x80 -#define MIDI_NOTE_ON 0x90 -#define MIDI_POLY_PRESSURE 0xA0 -#define MIDI_CONTROL 0xB0 -#define MIDI_PROGRAM 0xC0 -#define MIDI_CHAN_PRESSURE 0xD0 -#define MIDI_PITCHBEND 0xE0 -#define MIDI_SYSEX_START 0xF0 -#define MIDI_TIMECODE 0xF1 -#define MIDI_POSITION 0xF2 -#define MIDI_SONG 0xF3 -#define MIDI_TUNE 0xF6 -#define MIDI_SYSEX_END 0xF7 -#define MIDI_CLOCK 0xF8 -#define MIDI_START 0xFA -#define MIDI_CONTINUE 0xFB -#define MIDI_STOP 0xFC -#define MIDI_ACTIVE_SENSE 0xFE -#define MIDI_RESET 0xFF +static const uint8_t MIDI_NOTE_OFF = 0x80; +static const uint8_t MIDI_NOTE_ON = 0x90; +static const uint8_t MIDI_POLY_PRESSURE = 0xA0; +static const uint8_t MIDI_CONTROL = 0xB0; +static const uint8_t MIDI_PROGRAM = 0xC0; +static const uint8_t MIDI_CHAN_PRESSURE = 0xD0; +static const uint8_t MIDI_PITCHBEND = 0xE0; +static const uint8_t MIDI_SYSEX_START = 0xF0; +static const uint8_t MIDI_TIMECODE = 0xF1; +static const uint8_t MIDI_POSITION = 0xF2; +static const uint8_t MIDI_SONG = 0xF3; +static const uint8_t MIDI_TUNE = 0xF6; +static const uint8_t MIDI_SYSEX_END = 0xF7; +static const uint8_t MIDI_CLOCK = 0xF8; +static const uint8_t MIDI_START = 0xFA; +static const uint8_t MIDI_CONTINUE = 0xFB; +static const uint8_t MIDI_STOP = 0xFC; +static const uint8_t MIDI_ACTIVE_SENSE = 0xFE; +static const uint8_t MIDI_RESET = 0xFF; struct MIDI_MESSAGE { uint8_t command = 0; - uint8_t value1 = 0; - uint8_t value2 = 0; + uint8_t value1 = 0; + uint8_t value2 = 0; }; diff --git a/zynlibs/zynseq/file_format.txt b/zynlibs/zynseq/file_format.txt index 3dc24a8a8..4df89e324 100644 --- a/zynlibs/zynseq/file_format.txt +++ b/zynlibs/zynseq/file_format.txt @@ -1,6 +1,6 @@ zynseq file format (RIFF) ========================= -Version 10 +Version 11 Pattern time is measured in steps. Sequence time is measured in MIDI clock cycles. @@ -8,9 +8,10 @@ RIFF Header: (Version block must be first. If ommitted assume version 0.) Block ID: "vers" Block size: 32-bit big endian Block: - File format version [32] + File format version [4] Tempo [2] - Time signature (beats per bar) [2] + Time signature (beats per bar) [1] + Padding [1] MIDI channel for triggers and tallies [1] Trigger JACK input [1] (Not yet implemented) Trigger JACK output [1] (Not yet implemented) @@ -28,12 +29,13 @@ Block: Map / scale [1] (Added in V1) Scale tonic [1] (Added in V1) Reference note [1] (Added in V5. May be used to remember location in pattern editor) + CC interpolation mode [128] (Added in V11. Interpolation mode for each CC number from 0 to 127) Quantize notes [1] (Added in V9) Swing Divisor [1] (Added in V9) Swing Amount [4] (Added in V9. BCD with 4 decimal positions) - Timing Humanization (Added in V9. BCD with 4 decimal positions) - Velocity Humanization (Added in V9. BCD with 4 decimal positions) - Play Chance (Added in V9. BCD with 4 decimal positions) + Timing Humanization [4] (Added in V9. BCD with 4 decimal positions) + Velocity Humanization [4] (Added in V9. BCD with 4 decimal positions) + Play Chance [4] (Added in V9. BCD with 4 decimal positions) Padding [1] (Added in V5) Events: (quantity deduced from block length) Start step [4] @@ -47,40 +49,42 @@ Block: Stutter count [1] Stutter duration [1] Play chance [1] (Added in V9) - Unused padding [1] = 0 RIFF Header: Block ID: "bank" Block size: 32-bit big endian Block: Bank ID [1] - Padding [1] - Quantity of sequences (grid size) [4] - Sequences: - Play mode [1] - Group [1] + Quantity of phrases [1] + Quantity of sequences in each phrase [1] + Padding[1] + Sequences (Phrase sequence, followed by child (chain) sequences): + Play mode [1] + Repeats [1] + Group [1] Trigger note [1] (This seems like wrong place but makes code much simpler) - Padding [1] + Follow action [1] (Added in v11) + Follow action param[2] (Added in v11) + Padding [1] (Added in v11) Name [16] (Since V6) - Quantity of tracks [4] - Tracks: - Type [1] (Added in v10. 0=MIDI, 1=Audio) - Chain ID [1] (Added in v10. 0=None) - MIDI channel [1] - MIDI channel [1] - JACK output [1] - Keymap [1] (bits scale: 0..200, 254=map) - Padding [1] - Quantity of patterns [2] - Patterns: - Start time [4] - Pattern ID [4] - Quantity of timebase events [4] - Timebase events: - Event measure [2] - Event tick [2] - Event command [2] [0x0001: Tempo, 0x0002: Time signature] - Event data [2] (maybe variable data in future?) + Quantity of tracks [4] + Tracks: + Type [1] (Added in v10. 0=MIDI, 1=Audio) + Chain ID [1] (Added in v10. 0=None) + MIDI channel [1] + JACK output [1] + Keymap [1] (bits scale: 0..200, 254=map) + Padding [1] + Quantity of patterns [2] + Patterns: + Start time [4] + Pattern ID [4] + Quantity of timebase events [4] + Timebase events: + Event measure [2] + Event tick [2] + Event command [2] [0x0001: Tempo, 0x0002: Time signature] + Event data [2] (maybe variable data in future?) RIFF Header: (user defined scales) NOT IMPLEMENTED Block ID: 'scal' @@ -92,7 +96,6 @@ Block: Note offset [1] Pad if necessary [0/1] - **Patterns must be stored (or loaded) before sequences which rely on references to patterns** Keymap File Format diff --git a/zynlibs/zynseq/pattern.cpp b/zynlibs/zynseq/pattern.cpp index 983a50d55..82b3bf2a0 100644 --- a/zynlibs/zynseq/pattern.cpp +++ b/zynlibs/zynseq/pattern.cpp @@ -1,11 +1,15 @@ -#include "pattern.h" #include +#include +#include + +#include "pattern.h" /** Pattern class methods implementation **/ Pattern::Pattern(uint32_t beats, uint32_t stepsPerBeat) : m_nBeats(beats), m_nStepsPerBeat(stepsPerBeat) { setStepsPerBeat(stepsPerBeat); resetSnapshots(); + setInterpolateCCDefaults(); } Pattern::Pattern(Pattern* pattern) { *this = *pattern; } @@ -26,18 +30,20 @@ Pattern& Pattern::operator=(Pattern& p) { clear(); m_nBeats = p.getBeatsInPattern(); setStepsPerBeat(p.getStepsPerBeat()); - m_nScale = p.m_nScale; - m_nTonic = p.m_nTonic; - m_nRefNote = p.m_nRefNote; - m_bQuantizeNotes = p.m_bQuantizeNotes; - m_nSwingDiv = p.m_nSwingDiv; - m_fSwingAmount = p.m_fSwingAmount; - m_fHumanTime = p.m_fHumanTime; - m_fHumanVelo = p.m_fHumanVelo; - m_fPlayChance = p.m_fPlayChance; - m_nZoom = p.m_nZoom; + m_nScale = p.m_nScale; + m_nTonic = p.m_nTonic; + m_nRefNote = p.m_nRefNote; + m_nQuantizeNotes = p.m_nQuantizeNotes; + m_nSwingDiv = p.m_nSwingDiv; + m_fSwingAmount = p.m_fSwingAmount; + m_fHumanTime = p.m_fHumanTime; + m_fHumanVelo = p.m_fHumanVelo; + m_fPlayChance = p.m_fPlayChance; + m_nZoom = p.m_nZoom; + // Copy flags array + for (int i; i<128; i++) m_bInterpolateCC[i] = p.m_bInterpolateCC[i]; // Copy Events - uint32_t i = 0; + uint32_t i = 0; while (StepEvent* ev = p.getEventAt(i)) { addEvent(ev); i++; @@ -46,48 +52,176 @@ Pattern& Pattern::operator=(Pattern& p) { return *this; } +// add assignment (merge) +Pattern& Pattern::operator+=(Pattern& p) { + pastePattern(&p, 0, 0.0, 0, true); + return *this; +} + +// Paste (merge) a pattern into this +void Pattern::pastePattern(Pattern* p, int32_t dstep, float doffset, int8_t dnote, bool truncate) { + // Add note events from argument pattern into this pattern. Ignore other events. + uint32_t nsteps = getSteps(); + for (auto it = p->m_vEvents.begin(); it != p->m_vEvents.end(); ++it) { + StepEvent* ev = *it; + if (ev->m_nCommand != MIDI_NOTE_ON) continue; + // Calculate time offset + int32_t pos = ev->m_nPosition + dstep; + float offset = ev->m_fOffset + doffset; + if (offset >= 1.0) { + pos++; + offset -= 1.0; + } else if (offset <= -1.0) { + pos--; + offset = 1.0 - offset; + } + // Skip notes out off step-range + if (truncate) { + if (pos < 0 || pos >= nsteps) continue; + } + // Circular horizontal overflow + else { + // Move left overflowed notes to the end of pattern + if (pos < 0) { + pos += nsteps; + } + // Move right overflowed notes to the beggining of pattern + else if (pos >= nsteps) { + pos -= nsteps; + } + } + // Calculate note offset + int16_t note = int16_t(ev->m_nValue1start) + dnote; + // Skip notes out of note-range + if (note < 0 || note > 127) continue; + + // Add event to this pattern. It will overwrite existing notes in the same position. + StepEvent pasted_ev = *ev; + pasted_ev.m_nPosition = pos; + pasted_ev.m_fOffset = offset; + pasted_ev.m_nValue1start = note; + addEvent(&pasted_ev); + } +} + +// Returns a new pattern copying note events from this pattern, in the specified step & note range +Pattern* Pattern::getPatternSelection(uint32_t step1, uint32_t step2, uint8_t note1, uint8_t note2, bool cut) { + uint32_t nsteps = getSteps(); + + // Check range of offset parameters + if (step1 >= nsteps) step1 = nsteps - 1; + if (step2 >= nsteps) step2 = nsteps - 1; + if (note1 > 127) note1 = 127; + if (note2 > 127) note2 = 127; + + // Create an empty pattern for the result. The caller must delete when not needed anymore. + Pattern* res = new Pattern(m_nBeats, m_nStepsPerBeat); + + // Copy note events from this pattern into the result pattern. Ignore other events. + for (auto it = m_vEvents.begin(); it != m_vEvents.end(); /*don't increment here!*/) { + StepEvent* ev = *it; + if (ev->m_nCommand != MIDI_NOTE_ON || + ev->m_nPosition < step1 || ev->m_nPosition > step2 || + ev->m_nValue1start < note1 || ev->m_nValue1start > note2) { + it++; + continue; + } + res->addEvent(ev); + if (cut) { + delete ev; + it = m_vEvents.erase(it); + } else { + it++; + } + } + return res; +} + +// Get indexes of note events in a time & note range, upto the specified limit => ev_indexes +// Returns the number of event indexes copied into ev_indexes. +uint32_t Pattern::getPatternSelectionIndexes(uint32_t* ev_indexes, uint32_t limit, uint32_t step1, uint32_t step2, uint8_t note1, uint8_t note2) { + uint32_t nsteps = getSteps(); + + // Check range of offset parameters + if (step1 >= nsteps) step1 = nsteps - 1; + if (step2 >= nsteps) step2 = nsteps - 1; + if (note1 > 127) note1 = 127; + if (note2 > 127) note2 = 127; + + // Copy indexes of note events from argument pattern into this pattern. Ignore other events. + uint32_t i = 0; + for (auto it = m_vEvents.begin(); it != m_vEvents.end(); it++) { + StepEvent* ev = *it; + if (ev->m_nCommand != MIDI_NOTE_ON || + ev->m_nPosition < step1 || ev->m_nPosition > step2 || + ev->m_nValue1start < note1 || ev->m_nValue1start > note2) { + continue; + } + ev_indexes[i++] = std::distance(m_vEvents.begin(), it); + if (i >= limit) break; + } + return i; +} + StepEvent* Pattern::addEvent(uint32_t position, uint8_t command, uint8_t value1, uint8_t value2, float duration, float offset) { + uint8_t nStutterSpeed = 0; + uint8_t nStutterVelfx = 0; + uint8_t nStutterRamp = 0; + float fPlayChance = 1.0; + uint8_t nPlayFreq = 1; + float fStutterChance = 1.0; + uint8_t nStutterFreq = 1; // Delete overlapping events - uint8_t nStutterCount = 0; - uint8_t nStutterDur = 1; - uint8_t nFirstNote = 0; + bool bFirstNote = false; for (auto it = m_vEvents.begin(); it != m_vEvents.end(); ++it) { - uint32_t nEventStart = position; - float fEventEnd = nEventStart + duration; - uint32_t nCheckStart = (*it)->getPosition(); - float fCheckEnd = nCheckStart + (*it)->getDuration(); - bool bOverlap = (nCheckStart >= nEventStart && nCheckStart < fEventEnd) || (fCheckEnd > nEventStart && fCheckEnd <= fEventEnd); - if (bOverlap && (*it)->getCommand() == command && (*it)->getValue1start() == value1) { - if (!nFirstNote) { - nStutterCount = (*it)->getStutterCount(); - nStutterDur = (*it)->getStutterDur(); - nFirstNote = 1; + if ((*it)->getCommand() == command && (*it)->getValue1start() == value1) { + float fEventEnd = position + duration; + uint32_t nCheckStart = (*it)->getPosition(); + float fCheckEnd = nCheckStart + (*it)->getDuration(); + bool bOverlap = (nCheckStart >= position && nCheckStart < fEventEnd) || (fCheckEnd > position && fCheckEnd <= fEventEnd); + if (bOverlap) { + if (!bFirstNote) { + nStutterSpeed = (*it)->getStutterSpeed(); + nStutterVelfx = (*it)->getStutterVelfx(); + nStutterRamp = (*it)->getStutterRamp(); + fPlayChance = (*it)->getPlayChance(); + nPlayFreq = (*it)->getPlayFreq(); + fStutterChance = (*it)->getStutterChance(); + nStutterFreq = (*it)->getStutterFreq(); + bFirstNote = true; + } + delete *it; + it = m_vEvents.erase(it) - 1; + if (it == m_vEvents.end()) + break; } - delete *it; - it = m_vEvents.erase(it) - 1; - if (it == m_vEvents.end()) - break; } } uint32_t nTime = position % (m_nBeats * m_nStepsPerBeat); - auto it = m_vEvents.begin(); + auto it = m_vEvents.begin(); for (; it != m_vEvents.end(); ++it) { if ((*it)->getPosition() > position) break; } auto itInserted = m_vEvents.insert(it, new StepEvent(position, command, value1, value2, duration, offset)); - (*itInserted)->setStutterCount(nStutterCount); - (*itInserted)->setStutterDur(nStutterDur); + (*itInserted)->setStutter(nStutterSpeed, nStutterVelfx, nStutterRamp); + (*itInserted)->setPlayChance(fPlayChance); + (*itInserted)->setPlayFreq(nPlayFreq); + (*itInserted)->setStutterChance(fStutterChance); + (*itInserted)->setStutterFreq(nStutterFreq); return *itInserted; } StepEvent* Pattern::addEvent(StepEvent* pEvent) { - StepEvent* sev = addEvent(pEvent->getPosition(), pEvent->getCommand(), pEvent->getValue1start(), pEvent->getValue2start(), pEvent->getDuration(), pEvent->getOffset()); + StepEvent* sev = + addEvent(pEvent->getPosition(), pEvent->getCommand(), pEvent->getValue1start(), pEvent->getValue2start(), pEvent->getDuration(), pEvent->getOffset()); sev->setValue1end(pEvent->getValue1end()); sev->setValue2end(pEvent->getValue2end()); - sev->setStutterCount(pEvent->getStutterCount()); - sev->setStutterDur(pEvent->getStutterDur()); + sev->setStutter(pEvent->getStutterSpeed(), pEvent->getStutterVelfx(), pEvent->getStutterRamp()); sev->setPlayChance(pEvent->getPlayChance()); + sev->setPlayFreq(pEvent->getPlayFreq()); + sev->setStutterChance(pEvent->getStutterChance()); + sev->setStutterFreq(pEvent->getStutterFreq()); return sev; } @@ -111,6 +245,53 @@ bool Pattern::addNote(uint32_t step, uint8_t note, uint8_t velocity, float durat void Pattern::removeNote(uint32_t step, uint8_t note) { deleteEvent(step, MIDI_NOTE_ON, note); } +void Pattern::clearNotes() { + auto it = m_vEvents.begin(); + while (it != m_vEvents.end()) { + if ((*it)->getCommand() == MIDI_NOTE_ON) { + delete *it; + it = m_vEvents.erase(it); + } else { + ++it; + } + } +} + +int32_t Pattern::getNoteIndex(uint32_t step, uint8_t note) { + int index; + for (index = 0; index < m_vEvents.size(); ++index) { + StepEvent* ev = m_vEvents[index]; + if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) + return index; + } + return -1; +} + +int32_t Pattern::getNoteData(uint32_t step, uint8_t note, StepEvent* data) { + int index; + for (index = 0; index < m_vEvents.size(); ++index) { + StepEvent* ev = m_vEvents[index]; + if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) { + memcpy(data, ev, sizeof(StepEvent)); + return index; + } + } + return -1; +} + +int32_t Pattern::setNoteData(uint32_t step, uint8_t note, StepEvent* data) { + int index; + for (index = 0; index < m_vEvents.size(); ++index) { + StepEvent* ev = m_vEvents[index]; + if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) { + uint8_t pos = sizeof(uint32_t) + 2 * sizeof(float) + 2 * sizeof(uint8_t); + memcpy((uint8_t *)ev + pos, (uint8_t *)data + pos, sizeof(StepEvent) - pos); + return index; + } + } + return -1; +} + int32_t Pattern::getNoteStart(uint32_t step, uint8_t note) { for (StepEvent* ev : m_vEvents) if (ev->getPosition() <= step && int(std::ceil(ev->getPosition() + ev->getDuration())) > step && ev->getCommand() == MIDI_NOTE_ON && @@ -167,66 +348,72 @@ void Pattern::setNoteOffset(uint32_t step, uint8_t note, float offset) { } } -void Pattern::setStutter(uint32_t step, uint8_t note, uint8_t count, uint8_t dur) { +void Pattern::setStutter(uint32_t step, uint8_t note, uint8_t speed, uint8_t velfx, uint8_t ramp) { for (StepEvent* ev : m_vEvents) if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) { - if (ev->getDuration() > count * dur) { - ev->setStutterCount(count); - ev->setStutterDur(dur); - } + ev->setStutter(speed, velfx, ramp); return; } } -uint8_t Pattern::getStutterCount(uint32_t step, uint8_t note) { +uint8_t Pattern::getStutterSpeed(uint32_t step, uint8_t note) { for (StepEvent* ev : m_vEvents) { if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) { - return ev->getStutterCount(); + return ev->getStutterSpeed(); } } return 0; } -void Pattern::setStutterCount(uint32_t step, uint8_t note, uint8_t count) { - if (count > MAX_STUTTER_COUNT) - return; +void Pattern::setStutterSpeed(uint32_t step, uint8_t note, uint8_t speed) { for (StepEvent* ev : m_vEvents) { if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) { - // if (ev->getDuration() > count * ev->getStutterDur()) - ev->setStutterCount(count); + ev->setStutterSpeed(speed); return; } } } -uint8_t Pattern::getStutterDur(uint32_t step, uint8_t note) { +uint8_t Pattern::getStutterVelfx(uint32_t step, uint8_t note) { for (StepEvent* ev : m_vEvents) if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) - return ev->getStutterDur(); + return ev->getStutterVelfx(); return 1; } -void Pattern::setStutterDur(uint32_t step, uint8_t note, uint8_t dur) { - if (dur > MAX_STUTTER_DUR) - return; +void Pattern::setStutterVelfx(uint32_t step, uint8_t note, uint8_t velfx) { for (StepEvent* ev : m_vEvents) if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) { - // if (ev.getDuration() > dur * ev.getStutterCount()) - ev->setStutterDur(dur); + ev->setStutterVelfx(velfx); return; } } -uint8_t Pattern::getPlayChance(uint32_t step, uint8_t note) { +uint8_t Pattern::getStutterRamp(uint32_t step, uint8_t note) { + for (StepEvent* ev : m_vEvents) + if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) + return ev->getStutterRamp(); + return 1; +} + +void Pattern::setStutterRamp(uint32_t step, uint8_t note, uint8_t ramp) { + for (StepEvent* ev : m_vEvents) + if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) { + ev->setStutterRamp(ramp); + return; + } +} + +float Pattern::getPlayChance(uint32_t step, uint8_t note) { for (StepEvent* ev : m_vEvents) if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) return ev->getPlayChance(); - return 100; + return 1.0; } -void Pattern::setPlayChance(uint32_t step, uint8_t note, uint8_t chance) { - if (chance > 100) - chance = 100; +void Pattern::setPlayChance(uint32_t step, uint8_t note, float chance) { + if (chance > 1.0f) + chance = 1.0f; for (StepEvent* ev : m_vEvents) if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) { ev->setPlayChance(chance); @@ -234,6 +421,53 @@ void Pattern::setPlayChance(uint32_t step, uint8_t note, uint8_t chance) { } } +uint8_t Pattern::getPlayFreq(uint32_t step, uint8_t note) { + for (StepEvent* ev : m_vEvents) + if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) + return ev->getPlayFreq(); + return 1.0; +} + +void Pattern::setPlayFreq(uint32_t step, uint8_t note, uint8_t freq) { + for (StepEvent* ev : m_vEvents) + if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) { + ev->setPlayFreq(freq); + return; + } +} + +float Pattern::getStutterChance(uint32_t step, uint8_t note) { + for (StepEvent* ev : m_vEvents) + if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) + return ev->getStutterChance(); + return 1.0; +} + +void Pattern::setStutterChance(uint32_t step, uint8_t note, float chance) { + if (chance > 1.0f) + chance = 1.0f; + for (StepEvent* ev : m_vEvents) + if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) { + ev->setStutterChance(chance); + return; + } +} + +uint8_t Pattern::getStutterFreq(uint32_t step, uint8_t note) { + for (StepEvent* ev : m_vEvents) + if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) + return ev->getStutterFreq(); + return 1.0; +} + +void Pattern::setStutterFreq(uint32_t step, uint8_t note, uint8_t freq) { + for (StepEvent* ev : m_vEvents) + if (ev->getPosition() == step && ev->getCommand() == MIDI_NOTE_ON && ev->getValue1start() == note) { + ev->setStutterFreq(freq); + return; + } +} + bool Pattern::addProgramChange(uint32_t step, uint8_t program) { if (step >= (m_nBeats * m_nStepsPerBeat) || program > 127) return false; @@ -266,30 +500,48 @@ uint8_t Pattern::getProgramChange(uint32_t step) { bool Pattern::addControl(uint32_t step, uint8_t control, uint8_t valueStart, uint8_t valueEnd, float duration, float offset) { if (step > (m_nBeats * m_nStepsPerBeat) || control > 127 || valueStart > 127 || valueEnd > 127 || duration > (m_nBeats * m_nStepsPerBeat)) return false; + + if (m_bInterpolateCC[control]) stepControlEvents(control); StepEvent* pEvent = addEvent(step, MIDI_CONTROL, control, valueStart, duration, offset); - //!@todo Iterate through duration, interpolating value, adding events - pEvent->setValue2end(valueEnd); + pEvent->setValue2end(valueEnd); + if (m_bInterpolateCC[control]) joinControlEvents(control); + return true; } -void Pattern::removeControl(uint32_t step, uint8_t control) { deleteEvent(step, MIDI_CONTROL, control); } +void Pattern::removeControl(uint32_t step, uint8_t control) { + deleteEvent(step, MIDI_CONTROL, control); + if (m_bInterpolateCC[control]) joinControlEvents(control); +} void Pattern::removeControlInterval(uint32_t stepFrom, uint32_t stepTo, uint8_t control) { uint32_t step; if (stepTo >= stepFrom) { - for (step=stepFrom; step<=stepTo; step++) { + for (step = stepFrom; step <= stepTo; step++) { deleteEvent(step, MIDI_CONTROL, control); } } else { - for (step=0; step<=stepTo; step++) { - deleteEvent(step, MIDI_CONTROL, control); + for (step = 0; step <= stepTo; step++) { + deleteEvent(step, MIDI_CONTROL, control); } - for (step=stepFrom; stepgetCommand() == MIDI_CONTROL && (*it)->getValue1start() == control) { + delete *it; + it = m_vEvents.erase(it); + } else { + ++it; + } + } +} + int32_t Pattern::getControlStart(uint32_t step, uint8_t control) { for (StepEvent* ev : m_vEvents) if (ev->getPosition() <= step && int(std::ceil(ev->getPosition() + ev->getDuration())) > step && ev->getCommand() == MIDI_CONTROL && @@ -336,30 +588,83 @@ uint8_t Pattern::getControlValue(uint32_t step, uint8_t control) { return -1; } +uint8_t Pattern::getControlValueEnd(uint32_t step, uint8_t control) { + for (StepEvent* ev : m_vEvents) + if (ev->getPosition() == step && ev->getCommand() == MIDI_CONTROL && ev->getValue1start() == control) + return ev->getValue2end(); + return 0xff; +} + void Pattern::setControlValue(uint32_t step, uint8_t control, uint8_t valueStart, uint8_t valueEnd) { if (valueStart > 127 || valueEnd > 127) return; - for (StepEvent* ev : m_vEvents) - if (ev->getPosition() == step && ev->getCommand() == MIDI_CONTROL && ev->getValue1start() == control) { - ev->setValue2end(valueStart); - ev->setValue2start(valueEnd); - return; + auto it = m_vEvents.begin(); + for (; it != m_vEvents.end(); ++it) { + if ((*it)->getCommand() == MIDI_CONTROL && (*it)->getValue1start() == control) { + if ((*it)->getPosition() == step) { + (*it)->setValue2start(valueStart); + (*it)->setValue2end(valueEnd); + if (m_bInterpolateCC[control]) joinControlEvents(control); + break; + } } + } +} + +void Pattern::joinControlEvents(uint8_t control) { + if (m_vEvents.size() < 2) + return; + int32_t pos0 = -1; + uint8_t valueStart0; + uint8_t duration; + uint8_t valueEnd; + auto it = m_vEvents.begin(); + auto it_prev = m_vEvents.begin(); + for (; it != m_vEvents.end(); ++it) { + if ((*it)->getCommand() == MIDI_CONTROL && (*it)->getValue1start() == control) { + if (pos0 >= 0) { + duration = (*it)->getPosition() - (*it_prev)->getPosition(); + valueEnd = (*it)->getValue2start(); + (*it_prev)->setValue2end(valueEnd); + (*it_prev)->setDuration(duration); + //fprintf(stderr, "Join CC%u values => Pos=%u, Duration=%u, Start=%u, End=%u\n", control, (*it_prev)->getPosition(), duration, (*it_prev)->getValue2start(), valueEnd); + } else { + pos0 = (*it)->getPosition(); + valueStart0 = (*it)->getValue2start(); + } + it_prev = it; + } + } + duration = getSteps() - (*it_prev)->getPosition() + pos0; + (*it_prev)->setValue2end(valueStart0); + (*it_prev)->setDuration(duration); + //fprintf(stderr, "Join CC%u values => Pos=%u, Duration=%u, Start=%u, End=%u\n", control, (*it_prev)->getPosition(), duration, (*it_prev)->getValue2start(), valueEnd); +} + +void Pattern::stepControlEvents(uint8_t control) { + auto it = m_vEvents.begin(); + for (; it != m_vEvents.end(); ++it) { + if ((*it)->getCommand() == MIDI_CONTROL && (*it)->getValue1start() == control) { + (*it)->setValue2end((*it)->getValue2start()); + (*it)->setDuration(1); + //fprintf(stderr, "Step CC%u values => Pos=%u, Duration=%u, Start=%u, End=%u\n", control, (*it)->getPosition(), 1, (*it)->getValue2start(), (*it)->getValue2end()); + } + } } uint32_t Pattern::getSteps() { return (m_nBeats * m_nStepsPerBeat); } -uint32_t Pattern::getLength() { return m_nBeats * PPQN; } +uint32_t Pattern::getLength() { return m_nBeats * PPQN_INTERNAL; } uint32_t Pattern::getClocksPerStep() { - if (m_nStepsPerBeat > PPQN || m_nStepsPerBeat == 0) + if (m_nStepsPerBeat > PPQN_INTERNAL || m_nStepsPerBeat == 0) return 1; - return PPQN / m_nStepsPerBeat; + return PPQN_INTERNAL / m_nStepsPerBeat; } bool Pattern::setStepsPerBeat(uint32_t value) { float fScale = 1.0; - if (m_nStepsPerBeat == 0 || m_nStepsPerBeat > PPQN) + if (m_nStepsPerBeat == 0 || m_nStepsPerBeat > PPQN_INTERNAL) m_nStepsPerBeat = 4; else float fScale = float(value) / m_nStepsPerBeat; @@ -469,6 +774,20 @@ void Pattern::changeVelocityAll(int value) { } } +void Pattern::changeVelocityList(float value, uint32_t* evi_list, uint32_t n) { + for (uint32_t j = 0; j < n; ++j) { + StepEvent* ev = m_vEvents[evi_list[j]]; + if (ev->getCommand() != MIDI_NOTE_ON) + continue; + int vel = ev->getValue2start() + value; + if (vel > 127) + vel = 127; + if (vel < 1) + vel = 1; + ev->setValue2start(vel); + } +} + void Pattern::changeDurationAll(float value) { for (StepEvent* ev : m_vEvents) { if (ev->getCommand() != MIDI_NOTE_ON) @@ -482,29 +801,17 @@ void Pattern::changeDurationAll(float value) { } } -void Pattern::changeStutterCountAll(int value) { - for (StepEvent* ev : m_vEvents) { - if (ev->getCommand() != MIDI_NOTE_ON) - continue; - int count = ev->getStutterCount() + value; - if (count < 0) - count = 0; - if (count > 255) - count = 255; - ev->setStutterCount(count); - } -} - -void Pattern::changeStutterDurAll(int value) { - for (StepEvent* ev : m_vEvents) { +void Pattern::changeDurationList(float value, uint32_t* evi_list, uint32_t n) { + for (uint32_t j = 0; j < n; ++j) { + StepEvent* ev = m_vEvents[evi_list[j]]; if (ev->getCommand() != MIDI_NOTE_ON) continue; - int dur = ev->getStutterDur() + value; - if (dur < 1) - dur = 1; - if (dur > 255) - dur = 255; - ev->setStutterDur(dur); + float duration = ev->getDuration() + value; + if (duration <= 0) + return; // Don't allow jump larger than current value + if (duration < 0.1) //!@todo How short should we allow duration change? + duration = 0.1; + ev->setDuration(duration); } } @@ -534,14 +841,35 @@ void Pattern::setRefNote(uint8_t note) { m_nRefNote = note; } -bool Pattern::getQuantizeNotes() { return m_bQuantizeNotes; } +uint8_t Pattern::getQuantizeNotes() { return m_nQuantizeNotes; } + +void Pattern::setQuantizeNotes(uint8_t qn) { m_nQuantizeNotes = qn; } + +bool Pattern::getInterpolateCC(uint8_t ccnum) { return m_bInterpolateCC[ccnum]; } -void Pattern::setQuantizeNotes(bool flag) { m_bQuantizeNotes = flag; } +void Pattern::setInterpolateCC(uint8_t ccnum, bool flag) { + m_bInterpolateCC[ccnum] = flag; + if (flag) joinControlEvents(ccnum); + else stepControlEvents(ccnum); +} + +void Pattern::setInterpolateCCDefaults() { + int ccnum; + for (ccnum=0; ccnum<128; ccnum++) m_bInterpolateCC[ccnum] = true; + m_bInterpolateCC[64] = false; + m_bInterpolateCC[66] = false; + m_bInterpolateCC[67] = false; + m_bInterpolateCC[69] = false; + for (ccnum=0; ccnum<128; ccnum++) { + if (m_bInterpolateCC[ccnum]) joinControlEvents(ccnum); + else stepControlEvents(ccnum); + } +} -uint32_t Pattern::getLastStep() { +int32_t Pattern::getLastStep() { if (m_vEvents.size() == 0) return -1; - uint32_t nStep = 0; + int32_t nStep = 0; for (StepEvent* ev : m_vEvents) { if (ev->getPosition() > nStep) nStep = ev->getPosition(); diff --git a/zynlibs/zynseq/pattern.h b/zynlibs/zynseq/pattern.h index e43c16c9f..0615836d9 100644 --- a/zynlibs/zynseq/pattern.h +++ b/zynlibs/zynseq/pattern.h @@ -4,71 +4,135 @@ #include #include -#define MAX_STUTTER_COUNT 32 -#define MAX_STUTTER_DUR 96 - -extern uint32_t PPQN; +#define MAX_STUTTER_SPEED 32 +#define STUTTER_VELFX_NONE 0 +#define STUTTER_VELFX_FADEIN 1 +#define STUTTER_VELFX_FADEOUT 2 +#define MAX_STUTTER_VELFX 2 +#define STUTTER_RAMP_NONE 0 +#define STUTTER_RAMP_UP 1 +#define STUTTER_RAMP_DOWN 2 +#define MAX_STUTTER_RAMP 2 + +#define FLAG_CC_INTERPOLATION 1 /** StepEvent class provides an individual step event . - * The event may be part of a song, pattern or sequence. Events do not have MIDI channel which is applied by the function to play the event, e.g. pattern + * The event may be part of a scene, pattern or sequence. Events do not have MIDI channel which is applied by the function to play the event, e.g. pattern * player assigned to specific channel. Events have the concept of position which is an offset from some epoch measured in steps. The epoch depends on the * function using the event, e.g. pattern player may use start of pattern as epoch (position = 0). There is a starting and end value to allow interpolation of * MIDI events between the start and end positions. - */ +*/ +#pragma pack(1) // Set pack to 1 to allow binary interfacing from python ctypes class StepEvent { public: - /** Default constructor of StepEvent object - */ + uint32_t m_nPosition; // Start position of event in steps + float m_fOffset; // Offset of event position in steps + float m_fDuration; // Duration of event in steps + + uint8_t m_nCommand; // MIDI command without channel + uint8_t m_nValue1start; // MIDI value 1 at start of event + uint8_t m_nValue2start; // MIDI value 2 at start of event + uint8_t m_nValue1end; // MIDI value 1 at end of event + + uint8_t m_nValue2end; // MIDI value 2 at end of event + uint8_t m_nStutterSpeed; // Stutter speed in "retriggers every 2 steps" + uint8_t m_nStutterVelfx; // Stutter velocity FX (none=0, fade-out=1, fade-in=2) + uint8_t m_nStutterRamp; // Stutter speed ramp FX (none=0, ramp-up=1, ramp-down=2) + + uint8_t m_nPlayFreq; // Play/Skip note each N loops: last bit => play/skip, higher bits => loop count + // Can be used for enabling/disabling the event: 0 => play never, 1 => play on every loop + uint8_t m_nStutterFreq; // Play/Skip stutter each N loops: last bit => play/skip, higher bits => loop count + // Can be used for enabling/disabling the stutter: 0 => never stutter, 1 => stutter on every loop + float m_fPlayChance; // Probability of playing (0 = not played, 0.5 = plays with 50%, 1.0 = always plays) + float m_fStutterChance; // Probability of stutter (0 = not stutter, 0.5 = stutters with 50%, 1.0 = always stutters) + + /** Default constructor of StepEvent object +*/ StepEvent() { - m_nPosition = 0; - m_fOffset = 0.0; - m_fDuration = 1.0; - m_nCommand = MIDI_NOTE_ON; - m_nValue1start = 60; - m_nValue2start = 100; - m_nValue1end = 60; - m_nValue2end = 0; - m_nStutterCount = 0; - m_nStutterDur = 1; - m_nPlayChance = 100; + m_nPosition = 0; + m_fOffset = 0.0; + m_fDuration = 1.0; + m_nCommand = MIDI_NOTE_ON; + m_nValue1start = 60; + m_nValue2start = 100; + m_nValue1end = 60; + m_nValue2end = 0; + m_nStutterSpeed = 0; + m_nStutterVelfx = STUTTER_VELFX_NONE; + m_nStutterRamp = STUTTER_RAMP_NONE; + m_fPlayChance = 1.0f; + m_nPlayFreq = 1; + m_fStutterChance = 1.0f; + m_nStutterFreq = 1; }; /** Constructor - create an instance of StepEvent object - */ +*/ StepEvent(uint32_t position, uint8_t command, uint8_t value1 = 0, uint8_t value2 = 0, float duration = 1.0, float offset = 0.0) { - m_nPosition = position; - m_fOffset = offset; - m_fDuration = duration; - m_nCommand = command; + m_nPosition = position; + m_fOffset = offset; + m_fDuration = duration; + m_nCommand = command; m_nValue1start = value1; m_nValue2start = value2; - m_nValue1end = value1; + m_nValue1end = value1; if (command == MIDI_NOTE_ON) m_nValue2end = 0; else m_nValue2end = value2; - m_nStutterCount = 0; - m_nStutterDur = 1; - m_nPlayChance = 100; + m_nStutterSpeed = 0; + m_nStutterVelfx = STUTTER_VELFX_NONE; + m_nStutterRamp = STUTTER_RAMP_NONE; + m_fPlayChance = 1.0f; + m_nPlayFreq = 1; + m_fStutterChance = 1.0f; + m_nStutterFreq = 1; }; /** Copy constructor - create an copy of StepEvent object from an existing object - */ +*/ StepEvent(StepEvent* pEvent) { - m_nPosition = pEvent->getPosition(); - m_fOffset = pEvent->getOffset(); - m_fDuration = pEvent->getDuration(); - m_nCommand = pEvent->getCommand(); - m_nValue1start = pEvent->getValue1start(); - m_nValue2start = pEvent->getValue2start(); - m_nValue1end = pEvent->getValue1end(); - m_nValue2end = pEvent->getValue2end(); - m_nStutterCount = pEvent->getStutterCount(); - m_nStutterDur = pEvent->getStutterDur(); - m_nPlayChance = pEvent->getPlayChance(); + m_nPosition = pEvent->m_nPosition; + m_fOffset = pEvent->m_fOffset; + m_fDuration = pEvent->m_fDuration; + m_nCommand = pEvent->m_nCommand; + m_nValue1start = pEvent->m_nValue1start; + m_nValue2start = pEvent->m_nValue2start; + m_nValue1end = pEvent->m_nValue1end; + m_nValue2end = pEvent->m_nValue2end; + m_nStutterSpeed = pEvent->m_nStutterSpeed; + m_nStutterVelfx = pEvent->m_nStutterVelfx; + m_nStutterRamp = pEvent->m_nStutterRamp; + m_fPlayChance = pEvent->m_fPlayChance; + m_nPlayFreq = pEvent->m_nPlayFreq; + m_fStutterChance = pEvent->m_fStutterChance; + m_nStutterFreq = pEvent->m_nStutterFreq; }; + StepEvent& operator=(StepEvent& ev) { + // Guard self assignment + if (this == &ev) + return *this; + m_nPosition = ev.m_nPosition; + m_fOffset = ev.m_fOffset; + m_fDuration = ev.m_fDuration; + m_nCommand = ev.m_nCommand; + m_nValue1start = ev.m_nValue1start; + m_nValue2start = ev.m_nValue2start; + m_nValue1end = ev.m_nValue1end; + m_nValue2end = ev.m_nValue2end; + m_nStutterSpeed = ev.m_nStutterSpeed; + m_nStutterVelfx = ev.m_nStutterVelfx; + m_nStutterRamp = ev.m_nStutterRamp; + m_fPlayChance = ev.m_fPlayChance; + m_nPlayFreq = ev.m_nPlayFreq; + m_fStutterChance = ev.m_fStutterChance; + m_nStutterFreq = ev.m_nStutterFreq; + return *this; + } + + // Public Getters uint32_t getPosition() { return m_nPosition; } float getOffset() { return m_fOffset; } float getDuration() { return m_fDuration; } @@ -77,9 +141,15 @@ class StepEvent { uint8_t getValue2start() { return m_nValue2start; } uint8_t getValue1end() { return m_nValue1end; } uint8_t getValue2end() { return m_nValue2end; } - uint8_t getStutterCount() { return m_nStutterCount; } - uint8_t getStutterDur() { return m_nStutterDur; } - uint8_t getPlayChance() { return m_nPlayChance; } + uint8_t getStutterSpeed() { return m_nStutterSpeed; } + uint8_t getStutterVelfx() { return m_nStutterVelfx; } + uint8_t getStutterRamp() { return m_nStutterRamp; } + float getPlayChance() { return m_fPlayChance; } + uint8_t getPlayFreq() { return m_nPlayFreq; } + float getStutterChance() { return m_fStutterChance; } + uint8_t getStutterFreq() { return m_nStutterFreq; } + + // Public Setters void setPosition(uint32_t position) { m_nPosition = position; } void setOffset(float offset) { m_fOffset = offset; } void setDuration(float duration) { m_fDuration = duration; } @@ -87,441 +157,610 @@ class StepEvent { void setValue2start(uint8_t value) { m_nValue2start = value; } void setValue1end(uint8_t value) { m_nValue1end = value; } void setValue2end(uint8_t value) { m_nValue2end = value; } - void setStutterCount(uint8_t value) { m_nStutterCount = value; } - void setStutterDur(uint8_t value) { - if (value) - m_nStutterDur = value; + void setStutterSpeed(uint8_t value) { + if (value <= MAX_STUTTER_SPEED) + m_nStutterSpeed = value; + else + m_nStutterSpeed = MAX_STUTTER_SPEED; } - void setPlayChance(uint8_t chance) { m_nPlayChance = chance; } - - private: - uint32_t m_nPosition; // Start position of event in steps - float m_fOffset; // Offset of event position in steps - float m_fDuration; // Duration of event in steps - uint8_t m_nCommand; // MIDI command without channel - uint8_t m_nValue1start; // MIDI value 1 at start of event - uint8_t m_nValue2start; // MIDI value 2 at start of event - uint8_t m_nValue1end; // MIDI value 1 at end of event - uint8_t m_nValue2end; // MIDI value 2 at end of event - uint32_t m_nProgress; // Progress through event (start value to end value) - uint8_t m_nStutterCount; // Quantity of stutters (fast repeats) at start of event - uint8_t m_nStutterDur; // Duration of each stutter in clock cycles - uint8_t m_nPlayChance; // Probability of playing (0 = not played, 50 = plays with 50%, 100 = always plays) + void setStutterVelfx(uint8_t value) { + if (value <= MAX_STUTTER_VELFX) + m_nStutterVelfx = value; + else + m_nStutterVelfx = MAX_STUTTER_VELFX; + } + void setStutterRamp(uint8_t value) { + if (value <= MAX_STUTTER_RAMP) + m_nStutterRamp = value; + else + m_nStutterRamp = MAX_STUTTER_RAMP; + } + void setStutter(uint8_t speed, uint8_t velfx, uint8_t ramp) { + setStutterSpeed(speed); + setStutterVelfx(velfx); + setStutterRamp(ramp); + } + void setPlayChance(float chance) { m_fPlayChance = chance; } + void setPlayFreq(uint8_t freq) { m_nPlayFreq = freq; } + void setStutterChance(float chance) { m_fStutterChance = chance; } + void setStutterFreq(uint8_t freq) { m_nStutterFreq = freq; } }; - +#pragma pack() typedef std::vector StepEventVector; /** Pattern class provides a group of MIDI events within period of time - */ +*/ class Pattern { public: /** @brief Construct pattern object - * @param beats Quantity of beats in pattern [Optional - default:4] - * @param stepsPerBeat Quantity of steps per beat [Optional - default: 4] - */ - Pattern(uint32_t beats = 4, uint32_t stepsPerBeat = 4); + @param beats Quantity of beats in pattern [Optional - default:4] + @param stepsPerBeat Quantity of steps per beat [Optional - default: 4] + */ + Pattern(uint32_t beats = DEFAULT_BPB, uint32_t stepsPerBeat = 4); /** @brief Copy constructor - * @param Pointer to pattern to copy - */ + @param Pointer to pattern to copy + */ Pattern(Pattern* pattern); /** @brief Destruct pattern object - */ + */ ~Pattern(); /** @brief Copy operator - * @param p Pattern Reference to copy - */ + @param p Pattern Reference to copy + */ Pattern& operator=(Pattern& p); + /** @brief Copy+Add operator + @param p Pattern Reference to copy + */ + Pattern& operator+=(Pattern& p); + + /** @brief Paste (merge) a pattern into this + @param p Pointer to pattern to paste into this + @param dstep Quantity of steps to offset + @param doffset Fractional time offset + @param dnote Note offset + @param truncate False to use circular horizontal overflow. True to skip events out of step range. + */ + void pastePattern(Pattern* p, int32_t dstep=0, float doffset=0.0, int8_t dnote=0, bool truncate=false); + + /** @brief Create a (sub)pattern from this pattern, copying the events in the specified step & note range. + @param step1 step-range start + @param step2 step-range end + @param note1 note-range start + @param note2 note-range end + @param cut True to remove events from source pattern + @retval Pattern* Pointer to a newly created pattern with the copied events. The caller must delete when not needed anymore. + */ + Pattern* getPatternSelection(uint32_t step1=0, uint32_t step2=0xFFFFFFFF, uint8_t note1=0, uint8_t note2=127, bool cut=false); + + /** @brief Get indexes of note events in the specified time & note range. + @param ev_indexes pointer to integer array. It will be filled with the list of event indexes + @param limit size of integer array (ev_indexes) + @param step1 step-range start + @param step2 step-range end + @param note1 note-range start + @param note2 note-range end + @retval uint32_t the number of event indexes copied into ev_indexes. + */ + uint32_t getPatternSelectionIndexes(uint32_t* ev_indexes, uint32_t limit, uint32_t step1=0, uint32_t step2=0xFFFFFFFF, uint8_t note1=0, uint8_t note2=127); + /** @brief Add step event to pattern - * @param position Quantity of steps from start of pattern - * @param command MIDI command - * @param value1 MIDI value 1 - * @param value2 MIDI value 2 - * @param duration Event duration in steps cycles - */ + @param position Quantity of steps from start of pattern + @param command MIDI command + @param value1 MIDI value 1 + @param value2 MIDI value 2 + @param duration Event duration in steps cycles + */ StepEvent* addEvent(uint32_t position, uint8_t command, uint8_t value1 = 0, uint8_t value2 = 0, float duration = 1.0, float offset = 0.0); /** @brief Add event from existing event - * @param pEvent Pointer to event to copy - * @retval StepEvent* Pointer to new event - */ + @param pEvent Pointer to event to copy + @retval StepEvent* Pointer to new event + */ StepEvent* addEvent(StepEvent* pEvent); /** @brief Add note to pattern - * @param step Quantity of steps from start of pattern at which to add note - * @param note MIDI note number - * @param velocity MIDI velocity - * @param duration Duration of note in steps - * @param offset Step fraction, from 0.0 to 1.0 - * @retval bool True on success - */ + @param step Quantity of steps from start of pattern at which to add note + @param note MIDI note number + @param velocity MIDI velocity + @param duration Duration of note in steps + @param offset Step fraction, from 0.0 to 1.0 + @retval bool True on success + */ bool addNote(uint32_t step, uint8_t note, uint8_t velocity, float duration = 1.0, float offset = 0.0); /** @brief Remove note from pattern - * @param position Quantity of steps from start of pattern at which to remove note - * @param note MIDI note number - */ + @param position Quantity of steps from start of pattern at which to remove note + @param note MIDI note number + */ void removeNote(uint32_t step, uint8_t note); + /** @brief Remove all note events from pattern + */ + void clearNotes(); + + /** @brief Get index of a specified note + @param position Quantity of steps from start of pattern at which to check for note + @param note MIDI note number + @retval int32_t index of the note event in the events vector + */ + int32_t getNoteIndex(uint32_t step, uint8_t note); + + /** @brief Get data of a specified note + @param position Quantity of steps from start of pattern at which to check for note + @param note MIDI note number + @param data pointer to a struct to contain event data + @retval int32_t index of the note event in the events vector + */ + int32_t getNoteData(uint32_t step, uint8_t note, StepEvent* data); + + /** @brief Set data of a specified note, excluding position, offset, command and note number (nValue1Start) + @param position Quantity of steps from start of pattern at which to check for note + @param note MIDI note number + @param data pointer to a struct to contain event data + @retval int32_t index of the note event in the events vector + */ + int32_t setNoteData(uint32_t step, uint8_t note, StepEvent* data); + /** @brief Get step that note starts - * @param position Quantity of steps from start of pattern at which to check for note - * @param note MIDI note number - * @retval int32_t Quantity of steps from start of pattern that note starts or -1 if note not found - */ + @param position Quantity of steps from start of pattern at which to check for note + @param note MIDI note number + @retval int32_t Quantity of steps from start of pattern that note starts or -1 if note not found + */ int32_t getNoteStart(uint32_t step, uint8_t note); /** @brief Get velocity of note - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @retval uint8_t MIDI velocity of note - */ + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @retval uint8_t MIDI velocity of note + */ uint8_t getNoteVelocity(uint32_t step, uint8_t note); /** @brief Set velocity of note - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @param velocity MIDI velocity - */ + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @param velocity MIDI velocity + */ void setNoteVelocity(uint32_t step, uint8_t note, uint8_t velocity); /** @brief Get duration of note - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @retval float Duration of note or 0 if note does not exist - */ + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @retval float Duration of note or 0 if note does not exist + */ float getNoteDuration(uint32_t step, uint8_t note); /** @brief Get offset of note - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @retval float offset Step fraction, from 0.0 to 1.0 - */ + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @retval float offset Step fraction, from 0.0 to 1.0 + */ float getNoteOffset(uint32_t step, uint8_t note); /** @brief Set offset of note in selected pattern - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @param offset Step fraction, from 0.0 to 1.0 - */ + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @param offset Step fraction, from 0.0 to 1.0 + */ void setNoteOffset(uint32_t step, uint8_t note, float offset); /** @brief Set note stutter - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @param count Quantity of stutters - * @param dur Length of each stutter in clock cycles (min=1) - */ - void setStutter(uint32_t step, uint8_t note, uint8_t count, uint8_t dur); + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @param speed Speed in "retriggers every 2 steps" + @param velfx velocity speed FX (0=None, 1=fadeIn, 2=fadeOut) + @param ramp speed ramp FX (0=None, 1=down, 2=up) + */ + void setStutter(uint32_t step, uint8_t note, uint8_t speed, uint8_t velfx, uint8_t ramp); /** @brief Set note stutter count - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @param count Quantity of stutters - */ - void setStutterCount(uint32_t step, uint8_t note, uint8_t count); + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @param speed Speed in "retriggers every 2 steps" + */ + void setStutterSpeed(uint32_t step, uint8_t note, uint8_t speed); /** @brief Set note stutter duration - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @param dur Length of each stutter in clock cycles (min=1) - */ - void setStutterDur(uint32_t step, uint8_t note, uint8_t dur); - - /** @brief Get note stutter duration - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @retval uint8_t Duration of stutter each stutter in clock cycles - */ - uint8_t getStutterCount(uint32_t step, uint8_t note); - - /** @brief Get note stutter count - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @retval uint8_t Quantity of stutter repeats at start of note - */ - uint8_t getStutterDur(uint32_t step, uint8_t note); + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @param velfx velocity speed FX (0=None, 1=fadeIn, 2=fadeOut) + */ + void setStutterVelfx(uint32_t step, uint8_t note, uint8_t velfx); + + /** @brief Set note stutter duration + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @param ramp speed ramp FX (0=None, 1=down, 2=up) + */ + void setStutterRamp(uint32_t step, uint8_t note, uint8_t ramp); + + /** @brief Get note stutter spèed + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @retval uint8_t Speed in "retriggers every 2 steps" + */ + uint8_t getStutterSpeed(uint32_t step, uint8_t note); + + /** @brief Get note stutter velcity speed FX value + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @retval uint8_t velocity speed FX (0=None, 1=fadeIn, 2=fadeOut) + */ + uint8_t getStutterVelfx(uint32_t step, uint8_t note); + + /** @brief Get note stutter ramp FX value + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @retval uint8_t speed ramp FX (0=None, 1=down, 2=up) + */ + uint8_t getStutterRamp(uint32_t step, uint8_t note); /** @brief Set note play chance - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @param chance Note play probability from 0% to 100% - */ - void setPlayChance(uint32_t step, uint8_t note, uint8_t chance); + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @param chance Note play probability (0..1 for 0%..100%) + */ + void setPlayChance(uint32_t step, uint8_t note, float chance); /** @brief Get note play chance - * @param position Quantity of steps from start of pattern at which note starts - * @param note MIDI note number - * @retval uint8_t Chance, the note play probability from 0% to 100% - */ - uint8_t getPlayChance(uint32_t step, uint8_t note); + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @retval float Chance, the note play probability (0..1 for 0%..100%) + */ + float getPlayChance(uint32_t step, uint8_t note); + + /** @brief Set note play frequency + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @param freq Note play frequency: last bit => play/skip, higher bits => n loops to play/skip + Can be used for enabling/disabling the event: 0 => play never, 1 => play on every loop + */ + void setPlayFreq(uint32_t step, uint8_t note, uint8_t freq); + + /** @brief Get note play frequency + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @retval uint8_t Note play frequency: last bit => play/skip, higher bits => n loops to play/skip + */ + uint8_t getPlayFreq(uint32_t step, uint8_t note); + + /** @brief Set note stutter chance + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @param chance Stutter play probability (0..1 for 0%..100%) + */ + void setStutterChance(uint32_t step, uint8_t note, float chance); + + /** @brief Get stutter chance + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @retval float Chance, the stutter probability (0..1 for 0%..100%) + */ + float getStutterChance(uint32_t step, uint8_t note); + + /** @brief Set stutter frequency + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @param freq Stutter frequency: last bit => play/skip, higher bits => n loops to play/skip + */ + void setStutterFreq(uint32_t step, uint8_t note, uint8_t freq); + + /** @brief Get stutter frequency + @param position Quantity of steps from start of pattern at which note starts + @param note MIDI note number + @retval uint8_t Note play frequency: last bit => play/skip, higher bits => n loops to play/skip + */ + uint8_t getStutterFreq(uint32_t step, uint8_t note); /** @brief Add program change to pattern - * @param position Quantity of steps from start of pattern at which to add program change - * @param program MIDI program change number - * @retval bool True on success - */ + @param position Quantity of steps from start of pattern at which to add program change + @param program MIDI program change number + @retval bool True on success + */ bool addProgramChange(uint32_t step, uint8_t program); /** @brief Remove program change from pattern - * @param position Quantity of steps from start of pattern at which to remove program change - * @retval bool True on success - */ + @param position Quantity of steps from start of pattern at which to remove program change + @retval bool True on success + */ bool removeProgramChange(uint32_t step); /** @brief Get program change at a step - * @param position Quantity of steps from start of pattern at which program change resides - * @retval uint8_t Program change (0..127, 0xFF if no program change at this step) - */ + @param position Quantity of steps from start of pattern at which program change resides + @retval uint8_t Program change (0..127, 0xFF if no program change at this step) + */ uint8_t getProgramChange(uint32_t step); /** @brief Add continuous controller to pattern - * @param position Quantity of steps from start of pattern at which control starts - * @param control MIDI controller number - * @param valueStart Controller value at start of event - * @param valueEnd Controller value at end of event - * @param duration Duration of event in steps - * @param offset Step fraction, from 0.0 to 1.0 - * @retval bool True on success - */ + @param position Quantity of steps from start of pattern at which control starts + @param control MIDI controller number + @param valueStart Controller value at start of event + @param valueEnd Controller value at end of event + @param duration Duration of event in steps + @param offset Step fraction, from 0.0 to 1.0 + @retval bool True on success + */ bool addControl(uint32_t step, uint8_t control, uint8_t valueStart, uint8_t valueEnd, float duration = 1.0, float offset = 0.0); /** @brief Remove continuous controller from pattern - * @param position Quantity of steps from start of pattern at which control starts - * @param control MIDI controller number - */ + @param position Quantity of steps from start of pattern at which control starts + @param control MIDI controller number + */ void removeControl(uint32_t step, uint8_t control); /** @brief Remove continuous controller from pattern inside a interval of steps - * @param stepFrom Interval's start step (from start of pattern) - * @param stepTo Interval's end step (from start of pattern) - * @param control MIDI controller number - */ + @param stepFrom Interval's start step (from start of pattern) + @param stepTo Interval's end step (from start of pattern) + @param control MIDI controller number + */ void removeControlInterval(uint32_t stepFrom, uint32_t stepTo, uint8_t control); + /** @brief Remove all continuous controller events from pattern + @param control MIDI controller number + */ + void clearControl(uint8_t control); + /** @brief Get step that control starts - * @param position Quantity of steps from start of pattern at which to check for control - * @param control MIDI control number - * @retval int32_t Quantity of steps from start of pattern that control starts or -1 if control not found - */ + @param position Quantity of steps from start of pattern at which to check for control + @param control MIDI control number + @retval int32_t Quantity of steps from start of pattern that control starts or -1 if control not found + */ int32_t getControlStart(uint32_t step, uint8_t control); /** @brief Get duration of controller event - * @param position Quantity of steps from start of pattern at which control starts - * @param control MIDI controller number - * @retval float Duration of control or 0 if control does not exist - */ + @param position Quantity of steps from start of pattern at which control starts + @param control MIDI controller number + @retval float Duration of control or 0 if control does not exist + */ float getControlDuration(uint32_t step, uint8_t control); /** @brief Get offset of control - * @param position Quantity of steps from start of pattern at which control starts - * @param control MIDI control number - * @retval float offset Step fraction, from 0.0 to 1.0 - */ - float getControlOffset(uint32_t step, uint8_t control); - - /** @brief Set offset of control in selected pattern - * @param position Quantity of steps from start of pattern at which control starts - * @param note MIDI control number - * @param offset Step fraction, from 0.0 to 1.0 - */ - void setControlOffset(uint32_t step, uint8_t control, float offset); + @param position Quantity of steps from start of pattern at which control starts + @param control MIDI control number + @retval float offset Step fraction, from 0.0 to 1.0 + */ + float getControlOffset(uint32_t step, uint8_t control); + + /** @brief Set offset of control in selected pattern + @param position Quantity of steps from start of pattern at which control starts + @param note MIDI control number + @param offset Step fraction, from 0.0 to 1.0 + */ + void setControlOffset(uint32_t step, uint8_t control, float offset); /** @brief Get value of control - * @param position Quantity of steps from start of pattern at which control starts - * @param control MIDI control number - * @retval uint8_t MIDI value of control - */ - uint8_t getControlValue(uint32_t step, uint8_t control); - - /** @brief Set value of control - * @param position Quantity of steps from start of pattern at which control starts - * @param control MIDI control number - * @param valueStart MIDI value at start of event - * @param valueEdn MIDI value at end of event - */ - void setControlValue(uint32_t step, uint8_t control, uint8_t valueStart, uint8_t valueEnd); + @param position Quantity of steps from start of pattern at which control starts + @param control MIDI control number + @retval uint8_t MIDI value of control + */ + uint8_t getControlValue(uint32_t step, uint8_t control); + + /** @brief Get value end of control + @param position Quantity of steps from start of pattern at which control starts + @param control MIDI control number + @retval uint8_t end MIDI value of control + */ + uint8_t getControlValueEnd(uint32_t step, uint8_t control); + + /** @brief Set value of control + @param position Quantity of steps from start of pattern at which control starts + @param control MIDI control number + @param valueStart MIDI value at start of event + @param valueEdn MIDI value at end of event + */ + void setControlValue(uint32_t step, uint8_t control, uint8_t valueStart, uint8_t valueEnd); + + /** @brief Calculate durations and end values so CC events are joined and can be interpolated + @param control MIDI control number + */ + void joinControlEvents(uint8_t control); + + /** @brief Set duration and end values so CC events are stepped, not interpolated + @param control MIDI control number + */ + void stepControlEvents(uint8_t control); /** @brief Get quantity of steps in pattern - * @retval uint32_t Quantity of steps - */ + @retval uint32_t Quantity of steps + */ uint32_t getSteps(); /** @brief Get length of pattern in clock cycles - * @retval uint32_t Length of pattern in clock cycles - */ + @retval uint32_t Length of pattern in clock cycles + */ uint32_t getLength(); /** @brief Get quantity of clocks per step - * @retval uint32_t Quantity of clocks per step - */ + @retval uint32_t Quantity of clocks per step + */ uint32_t getClocksPerStep(); /** @brief Set quantity of steps per beat (grid line separation) - * @param value Quantity of steps per beat constrained to [1|2|3|4|6|8|12|24] - * @retval bool True on success - */ + @param value Quantity of steps per beat constrained to [1|2|3|4|6|8|12|24] + @retval bool True on success + */ bool setStepsPerBeat(uint32_t value); /** @brief Get quantity of steps per beat - * @retval uint32_t Quantity of steps per beat - */ + @retval uint32_t Quantity of steps per beat + */ uint32_t getStepsPerBeat(); /** @brief Set beats in pattern - * @param beats Quantity of beats in pattern - */ + @param beats Quantity of beats in pattern + */ void setBeatsInPattern(uint32_t beats); /** @brief Get beats in pattern - * @retval uint32_t Quantity of beats in pattern - */ + @retval uint32_t Quantity of beats in pattern + */ uint32_t getBeatsInPattern(); /** @brief Set map / scale used by pattern editor for this pattern - * @param map Index of map / scale - */ + @param map Index of map / scale + */ void setScale(uint8_t scale); /** @brief Get map / scale used by pattern editor for this pattern - * @retval uint8_t Index of map / scale - */ + @retval uint8_t Index of map / scale + */ uint8_t getScale(); /** @brief Set scale tonic (root note) used by pattern editor for current pattern - * @param tonic Scale tonic - */ + @param tonic Scale tonic + */ void setTonic(uint8_t tonic); /** @brief Get scale tonic (root note) used by pattern editor for current pattern - * @retval uint8_t Tonic - */ + @retval uint8_t Tonic + */ uint8_t getTonic(); /** @brief Set pattern's Swing Division - * @param div, swing amount from 0 to 1 (0.33 is perfect-triplet swing, >0.5 is not really swing) - */ + @param div, swing amount from 0 to 1 (0.33 is perfect-triplet swing, >0.5 is not really swing) + */ void setSwingDiv(uint32_t div); /** @brief Get pattern's Swing Amount - * @retval float - */ + @retval float + */ uint32_t getSwingDiv(); /** @brief Set pattern's Swing Amount - * @param amount, swing amount from 0 to 1 (0.33 is perfect-triplet swing, >0.5 is not really swing) - */ + @param amount, swing amount from 0 to 1 (0.33 is perfect-triplet swing, >0.5 is not really swing) + */ void setSwingAmount(float amount); /** @brief Get pattern's Swing Amount - * @retval float - */ + @retval float + */ float getSwingAmount(); /** @brief Set pattern's Time Humanization amount - * @param amount, from 0 to FLOAT_MAX - */ + @param amount, from 0 to FLOAT_MAX + */ void setHumanTime(float amount); /** @brief Get pattern's Time Humanization amount - * @retval float - */ + @retval float + */ float getHumanTime(); /** @brief Set pattern's Velocity Humanization amount - * @param amount, from 0 to FLOAT_MAX - */ + @param amount, from 0 to FLOAT_MAX + */ void setHumanVelo(float amount); /** @brief Get pattern's Velocity Humanization amount - * @retval float - */ + @retval float + */ float getHumanVelo(); /** @brief Set pattern's PlayChance - * @param chance, probability of playing notes - */ + @param chance, probability of playing notes + */ void setPlayChance(float chance); /** @brief Get pattern's PlayChance - * @retval float - */ + @retval float + */ float getPlayChance(); /** @brief Transpose all notes within pattern - * @param value Offset to transpose - */ + @param value Offset to transpose + */ void transpose(int value); - /** @brief Change velocity of all notes in patterm - * @param value Offset to adjust +/-127 - */ + /** @brief Change velocity of all notes in pattern + @param value Offset to adjust +/-127 + */ void changeVelocityAll(int value); - /** @brief Change duration of all notes in patterm - * @param value Offset to adjust +/-100.0 or whatever - */ - void changeDurationAll(float value); + /** @brief Change velocity of a list of notes in pattern + @param value Offset to adjust +/-127 + @param evi_list Event index list + @param n number of events in list + */ + void changeVelocityList(float value, uint32_t* evi_list, uint32_t n); - /** @brief Change stutter count of all notes in patterm - * @param value Offset to adjust +/-100 or whatever - */ - void changeStutterCountAll(int value); + /** @brief Change duration of all notes in pattern + @param value Offset to adjust +/-100.0 or whatever + */ + void changeDurationAll(float value); - /** @brief Change stutter dur of all notes in patterm - * @param value Offset to adjust +/-100 or whatever - */ - void changeStutterDurAll(int value); + /** @brief Change duration of a list of notes in pattern + @param value Offset to adjust +/-127 + @param evi_list Event index list + @param n number of events in list + */ + void changeDurationList(float value, uint32_t* evi_list, uint32_t n); /** @brief Clear all events from pattern - */ + */ void clear(); /** @brief Get event at given index - * @param index Index of event - * @retval StepEvent* Pointer to event or null if event does not existing - */ + @param index Index of event + @retval StepEvent* Pointer to event or null if event does not existing + */ StepEvent* getEventAt(uint32_t index); /** @brief Get index of first event at given time (step) - * @param step Index of step - * @retval uint32_t Index of event or -1 if not found - */ + @param step Index of step + @retval uint32_t Index of event or -1 if not found + */ int getFirstEventAtStep(uint32_t step); /** @brief Get quantity of events in pattern - * @retval size_t Quantity of events - */ + @retval size_t Quantity of events + */ size_t getEvents(); /** @brief Get the reference note - * @retval uint8_t MIDI note number - * @note May be used for position within user interface - */ + @retval uint8_t MIDI note number + @note May be used for position within user interface + */ uint8_t getRefNote(); /** @brief Set the reference note - * @param MIDI note number + @param MIDI note number May be used for position within user interface */ void setRefNote(uint8_t note); - /** @brief Get the "Quantize Notes" flag - * @retval bool flag - */ - bool getQuantizeNotes(); + /** @brief Get the "Quantize Notes" value + @retval uint8_t quantize value (0, 1, 2, 3, 4, 6, 8, 12, 16) + */ + uint8_t getQuantizeNotes(); + + /** @brief Set the "Quantize Notes" value + @param quantize value (0, 1, 2, 3, 4, 6, 8, 12, 16) + */ + void setQuantizeNotes(uint8_t qn); + + /** @brief Get the "Interpolate CC values" flag for a given CC number + @param ccnum + @retval bool flag + */ + bool getInterpolateCC(uint8_t ccnum); + + /** @brief Set the "Interpolate CC" flag for a given CC number + @param ccnum + @param flag + */ + void setInterpolateCC(uint8_t ccnum, bool flag); - /** @brief Set the "Quantize Notes" flag - * @param flag - */ - void setQuantizeNotes(bool flag); + /** @brief Set "Interpolate CC" flags to default values for each CC number + */ + void setInterpolateCCDefaults(); /** @brief Get last populated step - * @retval uint32_t Index of last step that contains any events or -1 if pattern is empty - */ - uint32_t getLastStep(); + @retval int32_t Index of last step that contains any events or -1 if pattern is empty + */ + int32_t getLastStep(); // Snapshot management: Undo/Redo void clearStepEventVector(StepEventVector* sev); @@ -536,7 +775,6 @@ class Pattern { // Grid zoom management void setZoom(int16_t zoom) { m_nZoom = zoom; } int16_t getZoom() { return m_nZoom; } - // TODO => Implement saving/restore of zoom value private: void deleteEvent(uint32_t position, uint8_t command, uint8_t value1); @@ -545,16 +783,17 @@ class Pattern { std::vector m_vSnapshots; // Vector of vectors of pattern events std::vector::iterator m_vSnapshotPos = m_vSnapshots.end(); // Iterator pointing to the current snapshot - uint32_t m_nBeats = 4; // Quantity of beats in pattern - uint32_t m_nStepsPerBeat = 6; // Steps per beat - uint8_t m_nScale = 0; // Index of scale - uint8_t m_nTonic = 0; // Scale tonic (root note) - uint8_t m_nRefNote = 60; // Note at which to position pattern editor - bool m_bQuantizeNotes = false; // Quantize note time so it plays in the nearest step boundary - uint32_t m_nSwingDiv = 1; // Swing division, range from 1 to pPattern->getStepsPerBeat() - float m_fSwingAmount = 0.0; // Swing amount, range from 0 to 1, but over 0.5 is not "MPC swing" - float m_fHumanTime = 0.0; // Timing Humanization, range from 0 to FLOAT_MAX - float m_fHumanVelo = 0.0; // Velocity Humanization, range from 0 to FLOAT_MAX - float m_fPlayChance = 1.0; // Probability for playing notes (0 = Notes are not played, 0.5 = Notes plays with 50%, 1 = All notes play always) - int16_t m_nZoom = 0; // Grid Zoom (pattern editor) + uint32_t m_nBeats = 4; // Quantity of beats in pattern + uint32_t m_nStepsPerBeat = 4; // Steps per beat + uint8_t m_nScale = 0; // Index of scale + uint8_t m_nTonic = 0; // Scale tonic (root note) + uint8_t m_nRefNote = 60; // Note at which to position pattern editor + uint8_t m_nQuantizeNotes = 0; // Quantize note time so it plays in the nearest step fraction boundary (1, 1/2, 1/3, 1/4, 1/6, 1/8, 1/12, 1/16) + bool m_bInterpolateCC[128]; // Enable/Disable CC value interpolation for each CC number + uint32_t m_nSwingDiv = 1; // Swing division, range from 1 to pPattern->getStepsPerBeat() + float m_fSwingAmount = 0.0; // Swing amount, range from 0 to 1, but over 0.5 is not "MPC swing" + float m_fHumanTime = 0.0; // Timing Humanization, range from 0 to FLOAT_MAX + float m_fHumanVelo = 0.0; // Velocity Humanization, range from 0 to FLOAT_MAX + float m_fPlayChance = 1.0; // Probability for playing notes (0 = Notes are not played, 0.5 = Notes plays with 50%, 1 = All notes play always) + int16_t m_nZoom = 0; // Grid Zoom (pattern editor) }; diff --git a/zynlibs/zynseq/sequence.cpp b/zynlibs/zynseq/sequence.cpp index 9d9c1ef10..b0229eac7 100644 --- a/zynlibs/zynseq/sequence.cpp +++ b/zynlibs/zynseq/sequence.cpp @@ -1,20 +1,44 @@ +/* Defines sequence class providing collection of tracks + * + * Copyright (c) 2020-2025 Brian Walton + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + #include "sequence.h" -Sequence::Sequence() { +Sequence::Sequence(Sequence* phraseSequence) { + m_pPhraseSequence = phraseSequence; + for (uint8_t nSeq = 0; nSeq < 32; ++nSeq) + m_aChildSequences[nSeq] = nullptr; addTrack(); // Ensure new sequences have at least one track } - -uint8_t Sequence::getGroup() { return m_nGroup; } +// +uint8_t Sequence::getGroup() { + return m_nGroup; +} void Sequence::setGroup(uint8_t group) { if (m_nGroup == group) return; - m_nGroup = group; + m_nGroup = group; m_bChanged = true; } uint32_t Sequence::addTrack(uint32_t track) { - auto it = m_vTracks.begin(); + auto it = m_vTracks.begin(); uint32_t nReturn = ++track; if (track == -1 || track >= m_vTracks.size()) { m_vTracks.emplace_back(); @@ -52,27 +76,74 @@ Track* Sequence::getTrack(size_t index) { return NULL; } -void Sequence::addTempo(uint16_t tempo, uint16_t bar, uint16_t tick) { - m_timebase.addTimebaseEvent(bar, tick, TIMEBASE_TYPE_TEMPO, tempo); +void Sequence::setTempo(float tempo) { + m_fTempo = tempo; +} + +float Sequence::getTempo() { + return m_fTempo; +} + +bool Sequence::setTimeSig(uint8_t sig) { + m_nTimeSig = sig; + bool bLenChange = false; + if (isPhraseLauncher()) { + // Iterate each sequence in phrase + for (uint8_t nSeq = 0; nSeq < 32; ++nSeq) { + Sequence* pChildSeq = m_aChildSequences[nSeq]; + if (!pChildSeq) continue; + Track* pTrack = pChildSeq->getTrack(0); + if (pTrack) { + Pattern* pPattern = pTrack->getPattern(0); + // For each empty pattern, adjust number of beats to fit exactly 1 bar => bpb + if (pPattern && pPattern->getLastStep() == -1) { + pPattern->setBeatsInPattern(sig); + bLenChange = true; + } + } + } + } m_bChanged = true; + return bLenChange; } -uint16_t Sequence::getTempo(uint16_t bar, uint16_t tick) { return m_timebase.getTempo(bar, tick); } +uint8_t Sequence::getTimeSig() { + return m_nTimeSig; +} -void Sequence::addTimeSig(uint16_t beatsPerBar, uint16_t bar) { +void Sequence::addTempo(float tempo, uint16_t bar, uint16_t tick) { + m_timebase.addTimebaseEvent(bar, tick, TIMEBASE_TYPE_TEMPO, tempo * 100); + m_bChanged = true; +} + +void Sequence::removeTempo(uint16_t bar, uint16_t tick) { + m_timebase.removeTimebaseEvent(bar, tick, TIMEBASE_TYPE_TEMPO); + m_bChanged = true; +} + +float Sequence::getTempoAt(uint16_t bar, uint16_t tick) { + return m_timebase.getTempo(bar, tick); +} + +void Sequence::addTimeSig(uint8_t timeSig, uint16_t bar) { if (bar < 1) bar = 1; - m_timebase.addTimebaseEvent(bar, 0, TIMEBASE_TYPE_TIMESIG, beatsPerBar); + m_timebase.addTimebaseEvent(bar, 0, TIMEBASE_TYPE_TIMESIG, timeSig); + m_bChanged = true; +} + +void Sequence::removeTimeSig(uint16_t bar) { + m_timebase.removeTimebaseEvent(bar, 0, TIMEBASE_TYPE_TIMESIG); m_bChanged = true; } -uint16_t Sequence::getTimeSig(uint16_t bar) { +uint8_t Sequence::getTimeSigAt(uint16_t bar) { if (bar < 1) bar = 1; TimebaseEvent* pEvent = m_timebase.getPreviousTimebaseEvent(bar, 1, TIMEBASE_TYPE_TIMESIG); if (pEvent) return pEvent->value; - return 4; + return 0; } Timebase* Sequence::getTimebase() { @@ -80,100 +151,150 @@ Timebase* Sequence::getTimebase() { return &m_timebase; } -uint8_t Sequence::getPlayMode() { return m_nMode; } +uint8_t Sequence::getPlayMode() { + return m_nMode; +} void Sequence::setPlayMode(uint8_t mode) { - if (mode > LASTPLAYMODE) - return; m_nMode = mode; - if (m_nMode == DISABLED) - m_nState = STOPPED; m_bChanged = true; } -uint8_t Sequence::getPlayState() { return m_nState; } +uint8_t Sequence::getPlayState() { + return m_nState; +} -void Sequence::setPlayState(uint8_t state) { +void Sequence::setPlayState(uint8_t state, bool updatePhrase) { + if (state == CHILD_STOPPING) { + for (auto pSequence: m_aChildSequences) { + if (pSequence) + pSequence->setPlayState(STOPPING); + } + } + if (state == STOPPING && m_nState == STOPPED) + return; uint8_t nState = m_nState; - if (m_nMode == DISABLED) + if (m_nRepeat == 0) // Disabled state = STOPPED; if (state == m_nState) return; - if ((m_nMode == ONESHOT || m_nMode == LOOP) && (state == STOPPING || state == STOPPING_SYNC)) + if ((m_nMode & MODE_END_IMMEDIATE) && (state == STOPPING || state == STOPPING_SYNC)) { state = STOPPED; + } m_nState = state; if (m_nState == STOPPED) - if (m_nMode == ONESHOT) { - m_nPosition = m_nLastSyncPos; - for (auto it = m_vTracks.begin(); it != m_vTracks.end(); ++it) - (*it).setPosition(m_nPosition); - } else - m_nPosition = 0; + m_nPosition = 0; + + if (updatePhrase) + updatePhraseState(); + m_bStateChanged |= (nState != m_nState); m_bChanged = true; + if (m_nState == STARTING || m_nState == STOPPED) + m_nCount = 0; } -uint32_t Sequence::getState() { return (m_nGroup << 16) | (m_nMode << 8) | m_nState; } +void Sequence::updatePhraseState() { + // Find which sequence is the phrase, this or its parent. + Sequence* pPhraseSequence = m_pPhraseSequence; + if (!pPhraseSequence) + pPhraseSequence = this; + uint8_t state = pPhraseSequence->getPlayState(); + if (state != STOPPED && state != CHILD_PLAYING && state != CHILD_STOPPING) + return; + for (auto pChildSequence: pPhraseSequence->m_aChildSequences) { + if (pChildSequence && (pChildSequence->getPlayState() & 1) && state != CHILD_STOPPING) { + pPhraseSequence->setPlayState(CHILD_PLAYING, false); + return; + } + } + pPhraseSequence->setPlayState(STOPPED, false); +} + +uint32_t Sequence::getState() { + return (m_nRepeat << 24) | (m_nGroup << 16) | (m_nMode << 8) | m_nState; +} -uint8_t Sequence::clock(uint32_t nTime, bool bSync, uint32_t nSamplesPerClock) { +uint8_t Sequence::clock(uint32_t nTime, bool bSync, uint8_t nTimeSig) { m_nCurrentTrack = 0; uint8_t nReturn = 0; - uint8_t nState = m_nState; + uint8_t nState = m_nState; + uint8_t nCountInc = (m_nState == STARTING) ? 0 : 1; + uint32_t nPulsesBar = PPQN_INTERNAL * nTimeSig; + bool bPhraseLauncher = isPhraseLauncher(); + // Start of bar if (bSync) { - if (m_nMode == ONESHOTSYNC && m_nState != STARTING) - m_nState = STOPPED; - if (m_nState == STARTING) - m_nState = PLAYING; - if (m_nState == RESTARTING) { - m_nState = PLAYING; - nState = PLAYING; + if (m_nMode & MODE_END_SYNC) { + if (m_nState == STOPPING) { + setPlayState(STOPPED); + m_nPosition = 0; + } } - if (m_nState == STOPPING && m_nMode == LOOPSYNC) - m_nState = STOPPED; - if (m_nState == STOPPING_SYNC) { - m_nState = STOPPED; + if (m_nState == STARTING) { + setPlayState(PLAYING); + if (bPhraseLauncher) { + nReturn |= CLOCK_TRIG_PHRASE; + if (m_fTempo) + nReturn |= CLOCK_TRIG_TEMPO; + if (m_nTimeSig) + nReturn |= CLOCK_TRIG_TIMESIG; + } + } else if (m_nState == STOPPING_SYNC) { + setPlayState(STOPPED); m_nPosition = 0; + } else if (m_nState == PLAYING && bPhraseLauncher) { + // Playing at start of bar so must be triggering phrase + nReturn |= CLOCK_TRIG_PHRASE; + if (m_fTempo) + nReturn |= CLOCK_TRIG_TEMPO; + if (m_nTimeSig) + nReturn |= CLOCK_TRIG_TIMESIG; } - if (m_nMode == ONESHOTSYNC || m_nMode == LOOPSYNC) - m_nPosition = 0; - m_nLastSyncPos = m_nPosition; - } else if (m_nState == RESTARTING) - m_nState = STARTING; - + } + // Still playing so iterate through tracks if (m_nState == PLAYING || m_nState == STOPPING || m_nState == STOPPING_SYNC) { - // Still playing so iterate through tracks + bool trig = false; for (auto it = m_vTracks.begin(); it != m_vTracks.end(); ++it) - nReturn |= (*it).clock(nTime, m_nPosition, nSamplesPerClock, bSync); + trig |= (*it).clock(nTime, m_nPosition, bSync); + if (trig) + nReturn |= CLOCK_TRIG_MIDI; ++m_nPosition; } - if (m_nPosition >= m_nLength) { - // End of sequence - switch (m_nMode) { - case ONESHOT: - case ONESHOTALL: - case ONESHOTSYNC: + + // End of sequence or phrase + if ((!bPhraseLauncher && (m_nPosition >= m_nLength)) || (bPhraseLauncher && (m_nPosition >= nPulsesBar))) { + if (m_nState == PLAYING) { + m_nCount += nCountInc; + m_nPosition = 0; + bool bFollow; + if (m_nRepeat == 255) + bFollow = (m_nCount * nPulsesBar >= m_nAutoFollow); + else + bFollow = (m_nCount >= m_nRepeat); + if (bFollow) { + // Follow action + nReturn |= CLOCK_TRIG_SEQEND; + if (m_pFollowSequence != this) + setPlayState(STOPPED); + } + } else { setPlayState(STOPPED); - break; - case LOOPSYNC: - case LOOPALL: - if (m_nState == PLAYING) { - m_nState = RESTARTING; - nState = RESTARTING; + for (auto pChildSeq: m_aChildSequences) { + if (pChildSeq) + pChildSeq->setPlayState(STOPPING_SYNC); // stopping_sync so that child sequences stop in sync } - case LOOP: - if (m_nState == STOPPING || m_nState == STOPPING_SYNC) - setPlayState(STOPPED); } - m_nPosition = 0; - m_nLastSyncPos = 0; + m_nPosition = 0; } m_bStateChanged |= (nState != m_nState); if (m_bStateChanged) { - m_bChanged |= true; + m_bChanged = true; m_bStateChanged = false; - return nReturn | 2; + if (m_nState == PLAYING) + m_pNextTimebaseEvent = m_timebase.getFirstTimebaseEvent(); } + return nReturn; } @@ -184,7 +305,7 @@ SEQ_EVENT* Sequence::getEvent() { SEQ_EVENT* pEvent; while (m_nCurrentTrack < m_vTracks.size()) { - pEvent = m_vTracks[m_nCurrentTrack].getEvent(); + pEvent = m_vTracks[m_nCurrentTrack].getEvent(m_nCount); if (pEvent) return pEvent; ++m_nCurrentTrack; @@ -192,15 +313,21 @@ SEQ_EVENT* Sequence::getEvent() { return NULL; } -void Sequence::updateLength() { - m_nLength = 0; - m_bEmpty = true; +void Sequence::updateLength(uint32_t length) { + m_nLength = length; + if (length) + return; + m_bEmpty = true; for (auto it = m_vTracks.begin(); it != m_vTracks.end(); ++it) { uint32_t nTrackLength = (*it).updateLength(); if (nTrackLength > m_nLength) m_nLength = nTrackLength; m_bEmpty &= (*it).isEmpty(); } + if (m_pPhraseSequence) + m_pPhraseSequence->updateAutoFollow(); + else + updateAutoFollow(); } uint32_t Sequence::getLength() { return m_nLength; } @@ -226,4 +353,85 @@ void Sequence::setName(std::string sName) { m_sName.resize(16); } -std::string Sequence::getName() { return m_sName; } +std::string Sequence::getName() { + return m_sName; +} + +void Sequence::setFollowSequence(Sequence* sequence, uint8_t action, int16_t param) { + m_pFollowSequence = sequence; + m_nFollowAction = action; + m_nFollowParam = param; +} + +Sequence* Sequence::getFollowSequence() { + return m_pFollowSequence; +} + +uint8_t Sequence::getFollowAction() { + return m_nFollowAction; +} + +int16_t Sequence::getFollowParam() { + return m_nFollowParam; +} + +void Sequence::setRepeat(uint8_t repeat) { + m_nRepeat = repeat; + if (m_pPhraseSequence) + m_pPhraseSequence->updateAutoFollow(); + else if (repeat == 255) + updateAutoFollow(); + else + m_nAutoFollow = 0; +} + +uint8_t Sequence::getRepeat() { + return m_nRepeat; +} + +void Sequence::updateAutoFollow() { + m_nAutoFollow = 0; + for (auto pSequence: m_aChildSequences) { + if (!pSequence) + continue; + uint32_t nDuration = pSequence->getLength() * pSequence->getRepeat(); + if (nDuration > m_nAutoFollow) + m_nAutoFollow = nDuration; + } +} + +void Sequence::setPlayed(uint8_t played) { + m_nCount = played; +} + +uint8_t Sequence::getPlayed() { + return m_nCount; +} + +bool Sequence::isPhraseLauncher() { + return m_pPhraseSequence == nullptr; +} + +bool Sequence::isPhraseEmpty() { + if (isPhraseLauncher()) { + for (uint8_t nSeq = 0; nSeq < 32; ++nSeq) { + Sequence* pChildSeq = m_aChildSequences[nSeq]; + if (!pChildSeq) continue; + Track* pTrack = pChildSeq->getTrack(0); + if (!pTrack) continue; + Pattern* pPattern = pTrack->getPattern(0); + if (pPattern && pPattern->getLastStep() >= 0) + return false; + } + return true; + } + return false; +} + +void Sequence::setPhrase(uint8_t phrase) { + m_nPhrase = phrase; +} + +uint8_t Sequence::getPhrase() { + return m_nPhrase; +} diff --git a/zynlibs/zynseq/sequence.h b/zynlibs/zynseq/sequence.h index f31e4cc87..b1052bd82 100644 --- a/zynlibs/zynseq/sequence.h +++ b/zynlibs/zynseq/sequence.h @@ -1,3 +1,22 @@ +/* Declares Sequence class collection of tracks + * + * Copyright (c) 2020-2025 Brian Walton + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. +*/ + #pragma once #include "constants.h" @@ -10,181 +29,295 @@ * A collection of tracks that will play in unison / simultaneously * A timebase track which allows change of tempo and time signature during playback * A sequence can be triggered as a linear song or a looping pad - */ +*/ class Sequence { public: /** @brief Create Sequence object - */ - Sequence(); + */ + Sequence(Sequence* phraseSequence); /** @brief Get sequence's mutually excusive group - * @retval uint32_t sequence's group - */ + @retval uint32_t sequence's group + */ uint8_t getGroup(); /** @brief Set sequence's mutually exclusive group - * @param group Index of group - */ + @param group Index of group + */ void setGroup(uint8_t group); /** @brief Get play mode - * @retval uint8_t Play mode - */ + @retval uint8_t Stop mode (bits 0..1). Start mode (bit 2) modes. + */ uint8_t getPlayMode(); /** @brief Set play mode - * @param mode Play mode [DISABLED | ONESHOT | LOOP | ONESHOTALL | LOOPALL | ONESHOTSYNC | LOOPSYNC] - */ + @param mode Stop mode (bits 0..1). Start mode (bit 2) modes. + */ void setPlayMode(uint8_t mode); /** @brief Get sequence's play state - * @retval uint8_t Play state [STOPPED | PLAYING | STOPPING | STARTING | RESTARTING | STOPPING_SYNC] - */ + @retval uint8_t Play state [STOPPED | PLAYING | STOPPING | STARTING | STOPPING_SYNC] + */ uint8_t getPlayState(); /** @brief Set sequence's play state - * @param state Play state [STOPPED | PLAYING | STOPPING] - */ - void setPlayState(uint8_t state); + @param state Play state [STOPPED | PLAYING | STOPPING] + @param updatePhrase True to update phrase play status (default True) + */ + void setPlayState(uint8_t state, bool updatePhrase = true); /** @brief Get sequence state - * @retval uint32_t Sequence state as 32-bit word [0x00, group, mode, play state] - */ + @retval uint32_t Sequence state as 32-bit word [repeat, group, mode, play state] + */ uint32_t getState(); + /** @brief Updates the play state of a phrase + */ + void updatePhraseState(); + /** @brief Add new track to sequence - * @param track Index of track afterwhich to add new track (Optional - default: add to end of sequence) - * @retval uint32_t Index of track added - */ + @param track Index of track afterwhich to add new track (Optional - default: add to end of sequence) + @retval uint32_t Index of track added + */ uint32_t addTrack(uint32_t track = -1); /** @brief Remove a track from the sequence - * @param track Index of track within sequence - * @retval bool True on success - */ + @param track Index of track within sequence + @retval bool True on success + */ bool removeTrack(size_t track); /** @brief Get quantity of tracks in sequence - * @retval uint32_t Quantity of tracks - */ + @retval uint32_t Quantity of tracks + */ uint32_t getTracks(); /** @brief Clear all tracks from sequence - */ + */ void clear(); /** @brief Get pointer to a track - * @param index Index of track within sequence - * @retval Track* Pointer to track or NULL if bad index - */ + @param index Index of track within sequence + @retval Track* Pointer to track or NULL if bad index + */ Track* getTrack(size_t index); + /** @brief Set tempo + @param tempo Tempo in BPM + */ + void setTempo(float tempo); + + /** @brief Get tempo + @retval float Tempo in BPM + */ + float getTempo(); + + /** @brief Set time signature + @param sig Time signature (beats per bar) + @retval bool True if any pattern / sequence lengths changed + */ + bool setTimeSig(uint8_t sig); + + /** @brief Get time signature + @retval uint8_t Time signature (beats per bar) + */ + uint8_t getTimeSig(); + /** @brief Add tempo event to timebase track - * @param tempo Tempo in BPM - * @param bar Bar (measure) at which to set tempo - * @param tick Tick at which to set tempo [Optional - default: 0] - * @note Removes tempo if same as previous tempo - */ - void addTempo(uint16_t tempo, uint16_t bar, uint16_t tick = 0); + @param tempo Tempo in BPM + @param bar Bar (measure) at which to set tempo [Optional - default: 1] + @param tick Tick at which to set tempo [Optional - default: 0] + @note Removes tempo if same as previous tempo + */ + void addTempo(float tempo, uint16_t bar = 1, uint16_t tick = 0); + + /** @brief Remove tempo event from timebase track + @param bar Bar (measure) at which to set tempo [Optional - default: 1] + @param tick Tick at which to set tempo [Optional - default: 0] + */ + void removeTempo(uint16_t bar = 1, uint16_t tick = 0); /** @brief Get tempo from timebase track - * @param bar Bar (measure) at which to get tempo - * @param beat Tick at which to get tempo [Optional - default: 0] - * @retval uint16_t Tempo in BPM - */ - uint16_t getTempo(uint16_t bar, uint16_t tick = 0); + @param bar Bar (measure) at which to get tempo + @param beat Tick at which to get tempo [Optional - default: 0] + @retval float Tempo in BPM or 0.0 if no tempo in timebase + */ + float getTempoAt(uint16_t bar, uint16_t tick = 0); /** @brief Add time signature to timebase track - * @param beatsPerBar Beats per bar - * @param bar Bar (measure) at which to set time signature - * @note Removes time signature if same as previous time signature - */ - void addTimeSig(uint16_t beatsPerBar, uint16_t bar); + @param timeSig Beats per bar + @param bar Bar (measure) at which to set time signature [Optional - default: 1] + @note Removes time signature if same as previous time signature + */ + void addTimeSig(uint8_t timeSig, uint16_t bar = 1); + + /** @brief Remove time signature from timebase track + @param bar Bar (measure) at which to remove time signature + @param timeSig Beats per bar + */ + void removeTimeSig(uint16_t bar); /** @brief Get time signature from timebase track - * @param bar Bar (measure) at which to get time signature - * @retval uint16_t Beats per bar - */ - uint16_t getTimeSig(uint16_t bar); + @param bar Bar (measure) at which to get time signature + @retval uint16_t Beats per bar + */ + uint8_t getTimeSigAt(uint16_t bar); /** @brief Get pointer to timebase track - * @retval Timebase* Pointer to timebase map - */ + @retval Timebase* Pointer to timebase map + */ Timebase* getTimebase(); /** @brief Handle clock signal - * @param nTime Time (quantity of samples since JACK epoch) - * @param bSync True to indicate sync pulse, e.g. to sync tracks - * @param nSamplesPerClock Samples per clock - * @retval uint8_t Bitwise flag of what clock triggers [1=track step | 2=change of state] - * @note Sequences are clocked syncronously but not locked to absolute time so depend on start time for absolute timing - * @note Will clock each track - */ - uint8_t clock(uint32_t nTime, bool bSync, uint32_t nSamplesPerClock); + @param nTime Time (quantity of ticks since tick epoch) + @param bSync True to indicate sync pulse, e.g. to sync tracks + @param nTimeSig Beats per bar + @retval uint8_t Bitwise flag of what clock triggers (See CLOCK_TRIG_ constants) + @note Sequences are clocked syncronously but not locked to absolute time so depend on start time for absolute timing + @note Will clock each track + */ + uint8_t clock(uint32_t nTime, bool bSync, uint8_t nTimeSig); /** @brief Gets next event at current clock cycle - * @retval SEQ_EVENT* Pointer to sequence event at this time or NULL if no more events - * @note Start, end and interpolated events are returned on each call. Time is offset from start of clock cycle in samples. - */ + @retval SEQ_EVENT* Pointer to sequence event at this time or NULL if no more events + @note Start, end and interpolated events are returned on each call. Time is offset from start of clock cycle in ticks. + */ SEQ_EVENT* getEvent(); /** @brief Updates sequence length from track lengths - */ - void updateLength(); + @param length Optional length to force sequence to + */ + void updateLength(uint32_t length=0); /** @brief Get sequence length - * @retval uint32_t Length of sequence (longest track) in clock cycles - */ + @retval uint32_t Length of sequence (longest track) in clock cycles + */ uint32_t getLength(); - /** @brief Check if sequence is empty - * @retval bool True if empty. False if any patterns have any events) - */ + /** @brief Check if sequence is empty + @retval bool True if empty. False if any patterns have any events) + */ bool isEmpty(); /** @brief Set position of playback within sequence - * @param position Postion in clock cycles from start of sequence - */ + @param position Postion in clock cycles from start of sequence + */ void setPlayPosition(uint32_t position); /** @brief Get position of playback within sequence - * @retval uint32_t Postion in clock cycles from start of sequence - */ + @retval uint32_t Postion in clock cycles from start of sequence + */ uint32_t getPlayPosition(); - /** @brief Flag sequence as modified - */ + /** @brief Flag sequence as modified + */ void setModified(); /** @brief Check if sequence state has changed since last call - * @retval bool True if changed - * @note Monitors group, mode, tracks, playstate - */ + @retval bool True if changed + @note Monitors group, mode, tracks, playstate + */ bool isModified(); /** @brief Set sequence name - * @param std::string Sequence name (will be truncated at 16 characters) - */ + @param std::string Sequence name (will be truncated at 16 characters) + */ void setName(std::string sName); /** @brief Get sequence name - * @retval std::string Sequence name (maximum 16 characters) - */ + @retval std::string Sequence name (maximum 16 characters) + */ std::string getName(); + /** @brief Set follow sequence + @param sequence Pointer to follow sequence + @param action Follow action @see FOLLOW_ACTION enum + @param param Follow action parameter + */ + void setFollowSequence(Sequence* pSequence, uint8_t action, int16_t param); + + /** @brief Get follow sequence + @retval Sequence* Pointer to follow sequence + */ + Sequence* getFollowSequence(); + + /** @brief Get follow action + @retval uint8_t Follow action + */ + uint8_t getFollowAction(); + + /** @brief Get follow action parameter + @retval int16_t Follow action parameter + */ + int16_t getFollowParam(); + + /** @brief Set times to play + @param repeat Quantity of times to play (0 to disable) + */ + void setRepeat(uint8_t repeat); + + /** @brief Get times to play + @retval uint8_t Quantity of times to play (0 to disable) + */ + uint8_t getRepeat(); + + /** @brief Update the duration of a phrase launcher set to auto follow mode + */ + void updateAutoFollow(); + + /** @brief Set times played + */ + void setPlayed(uint8_t played); + + /** @brief Get times played + */ + uint8_t getPlayed(); + + /** @brief Is this sequence a phrase launcher? + @retval bool True if phrase launcher + */ + bool isPhraseLauncher(); + + /** @brief Check if sequence is an empty phrase + @retval bool True if some sequence have events. False if any patterns have any events) + */ + bool isPhraseEmpty(); + + /** @brief Sets phrase the sequence belongs + @param phrase Index of phrase (0xff for none) + */ + void setPhrase(uint8_t phrase); + + /** @brief Gets phrase the sequence belongs + @retval uint8_t Phrase Index of phrase (0xff for none) + */ + uint8_t getPhrase(); + + Sequence* m_aChildSequences[32]; // List of pointers to sequences in phrase + private: - std::vector m_vTracks; // Vector of tracks within sequence - Timebase m_timebase; // Timebase map - uint8_t m_nState = STOPPED; // Play state of sequence - uint8_t m_nMode = LOOPALL; // Play mode of sequence - size_t m_nCurrentTrack = 0; // Index of track currently being queried for events - uint32_t m_nPosition = 0; // Play position in clock cycles - uint32_t m_nLastSyncPos = 0; // Position of last sync pulse in clock cycles - uint32_t m_nLength = 0; // Length of sequence in clock cycles (longest track) - uint8_t m_nGroup = 0; // Sequence's mutually exclusive group - uint16_t m_nTempo = 120; // Default tempo (overriden by tempo events in timebase map) - bool m_bChanged = false; // True if sequence content changed - bool m_bStateChanged = false; // True if state changed since last clock cycle - bool m_bEmpty = true; // True if all patterns are emtpy (no events) - std::string m_sName; // Sequence name + std::vector m_vTracks; // Vector of tracks within sequence + Timebase m_timebase; // Timebase map + TimebaseEvent* m_pNextTimebaseEvent = NULL; // Pointer to next timebase event or NULL if none. + size_t m_nCurrentTrack = 0; // Index of track currently being queried for events + uint32_t m_nPosition = 0; // Play position in clock cycles + uint32_t m_nLength = 0; // Length of sequence in clock cycles (longest track) + float m_fTempo = 0.0; // Tempo (0.0 for none) - Only used by phrases + uint8_t m_nTimeSig = 0; // Time signature in ticks per bar (0 for none, PPQN_INTERNAL ticks per beat) - Only used by phrases + uint8_t m_nState = STOPPED; // Play state of sequence + uint8_t m_nMode = MODE_END_SYNC; // Bitwise flags: stop mode (bits 0..1), start mode (bit 2) + uint8_t m_nGroup = 0; // Sequence's mutually exclusive group + uint8_t m_nRepeat = 0; // Quantity of times to play sequence. 0 to disable, 255 for auto follow time + uint32_t m_nAutoFollow = 0; // Calculated duration (pulses) before auto follow action - Only used by phrases + uint8_t m_nCount = 0; // Quantity of times to sequence has played + uint8_t m_nPhrase = 0xff; // Index of phrase this sequence belongs - 0xff for none + bool m_bChanged = false; // True if sequence content changed + bool m_bStateChanged = false; // True if state changed since last clock cycle + bool m_bEmpty = true; // True if all patterns are emtpy (no events) + std::string m_sName; // Sequence name + uint8_t m_nFollowAction = FOLLOW_ACTION_NONE; // Follow action to perform when sequence ends + int16_t m_nFollowParam = 0; // Follow action parameter + Sequence* m_pFollowSequence = nullptr; // Pointer to follow sequence (null for no follow action) + Sequence* m_pPhraseSequence = nullptr; // Pointer to phrase sequence (null if not in a phrase, e.g. is a phrase) }; diff --git a/zynlibs/zynseq/sequencemanager.cpp b/zynlibs/zynseq/sequencemanager.cpp index 7ed513797..37079a624 100644 --- a/zynlibs/zynseq/sequencemanager.cpp +++ b/zynlibs/zynseq/sequencemanager.cpp @@ -1,85 +1,98 @@ +/* Defines SequenceManager class managing collection of sequences + * + * Copyright (c) 2020-2025 Brian Walton + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + #include "sequencemanager.h" #include #include +#include // provides remove_if /** SequenceManager class methods implementation **/ -SequenceManager::SequenceManager() { init(); } +SequenceManager::SequenceManager() { + for (uint8_t channel = 0; channel < 32; ++ channel) + m_bEnabled[channel] = false; + init(); +} void SequenceManager::init() { stop(); m_mTriggers.clear(); + for (auto it = m_mPatterns.begin(); it != m_mPatterns.end(); ++it) + delete (it->second); m_mPatterns.clear(); - resetBanks(); -} - -void SequenceManager::resetBanks() { - for (auto itBank = m_mBanks.begin(); itBank != m_mBanks.end(); ++itBank) - for (auto itSeq = itBank->second.begin(); itSeq != itBank->second.end(); ++itSeq) - delete (*itSeq); - m_mBanks.clear(); -} - -int SequenceManager::fileWrite32(uint32_t value, FILE* pFile) { - for (int i = 3; i >= 0; --i) - fileWrite8((value >> i * 8), pFile); - return 4; -} - -int SequenceManager::fileWrite16(uint16_t value, FILE* pFile) { - for (int i = 1; i >= 0; --i) - fileWrite8((value >> i * 8), pFile); - return 2; -} - -int SequenceManager::fileWrite8(uint8_t value, FILE* pFile) { - int nResult = fwrite(&value, 1, 1, pFile); - return 1; + for (auto& scene: m_vScenes) { + for (auto phrase: scene) { + for (auto seq: phrase->m_aChildSequences) { + delete seq; + } + delete phrase; + } + } + m_vScenes.clear(); + for (uint8_t channel = 0; channel < 32; ++channel) + enableChannel(channel, m_bEnabled[channel]); + m_nTimeSig = DEFAULT_BPB; + getPattern(0); // Create pattern 0 so that getNextPattern always has a valid starting point. + setScene(0); } -uint8_t SequenceManager::fileRead8(FILE* pFile) { - uint8_t nResult = 0; - fread(&nResult, 1, 1, pFile); - return nResult; +bool SequenceManager::setScene(uint8_t scene) { + bool bResult = scene >= m_vScenes.size(); + if (bResult) { + for (uint8_t i = m_vScenes.size(); i <= scene; ++i) { + m_vScenes.emplace_back(); + } + } + m_nScene = scene; + //fprintf(stderr, "%s scene %u\n", bResult?"Created":"Selected", scene); + return bResult; } -uint16_t SequenceManager::fileRead16(FILE* pFile) { - uint16_t nResult = 0; - for (int i = 1; i >= 0; --i) { - uint8_t nValue; - fread(&nValue, 1, 1, pFile); - nResult |= nValue << (i * 8); - } - return nResult; +uint8_t SequenceManager::getScene() { + return m_nScene; } -uint32_t SequenceManager::fileRead32(FILE* pFile) { - uint32_t nResult = 0; - for (int i = 3; i >= 0; --i) { - uint8_t nValue; - fread(&nValue, 1, 1, pFile); - nResult |= nValue << (i * 8); - } - return nResult; +void SequenceManager::removeScene(uint8_t scene) { + if (scene >= m_vScenes.size()) + return; + while (getNumPhrases(scene)) + removePhrase(scene, 0); + m_vScenes.erase(m_vScenes.begin() + scene); + if (m_nScene == scene) + setScene(0); + else + setScene(m_nScene); } -bool SequenceManager::checkBlock(FILE* pFile, uint32_t nActualSize, uint32_t nExpectedSize) { - if (nActualSize < nExpectedSize) { - for (size_t i = 0; i < nActualSize; ++i) - fileRead8(pFile); - return true; - } - return false; +uint8_t SequenceManager::getNumScenes() { + return m_vScenes.size(); } Pattern* SequenceManager::getPattern(uint32_t index) { - m_mPatterns[index]; // Ensure pattern exists and won't move in memory before accessing by pointer - return &(m_mPatterns[index]); + if (m_mPatterns.find(index) == m_mPatterns.end()) + m_mPatterns[index] = new Pattern(); + return m_mPatterns[index]; } uint32_t SequenceManager::getPatternIndex(Pattern* pattern) { for (auto it = m_mPatterns.begin(); it != m_mPatterns.end(); ++it) - if (&(it->second) == pattern) + if (it->second == pattern) return it->first; return -1; // NOT_FOUND } @@ -91,143 +104,317 @@ uint32_t SequenceManager::getNextPattern(uint32_t pattern) { return it->first; } -uint32_t SequenceManager::createPattern() { - uint32_t nSize = m_mPatterns.size(); - for (uint32_t nIndex = 1; nIndex <= nSize; ++nIndex) { - if (m_mPatterns.find(nIndex) != m_mPatterns.end()) - continue; - m_mPatterns[nIndex]; // Insert a default pattern - return nIndex; - } - m_mPatterns[++nSize]; // Append a default pattern - return nSize; +uint32_t SequenceManager::createPattern(uint32_t beats) { + uint32_t pattern = 0; + while (m_mPatterns.find(++pattern) != m_mPatterns.end()) + ; + m_mPatterns[pattern] = new Pattern(beats); // Insert a default pattern + return pattern; } -void SequenceManager::deletePattern(uint32_t index) { m_mPatterns.erase(index); } - -void SequenceManager::replacePattern(uint32_t index, Pattern* pattern) { m_mPatterns[index] = *pattern; } +void SequenceManager::deletePattern(uint32_t index) { + if (m_mPatterns.find(index) != m_mPatterns.end()) { + delete (m_mPatterns[index]); + m_mPatterns.erase(index); + } +} void SequenceManager::copyPattern(uint32_t source, uint32_t destination) { if (source == destination) return; - m_mPatterns[destination] = m_mPatterns[source]; + Pattern* pPattern = getPattern(destination); + *pPattern = *(m_mPatterns[source]); } -Sequence* SequenceManager::getSequence(uint8_t bank, uint8_t sequence) { - // Add missing sequences - while (m_mBanks[bank].size() <= sequence) { - m_mBanks[bank].push_back(new Sequence()); - addPattern(bank, m_mBanks[bank].size() - 1, 0, 0, createPattern(), false); +void SequenceManager::setPatternModified(Pattern* pPattern) { + for (auto scene: m_vScenes) { + for (auto phrase: scene) { + for (auto pSequence: phrase->m_aChildSequences) { + if (!pSequence) + continue; + bool bFound = false; + for (uint32_t nTrack = 0; nTrack < pSequence->getTracks() && !bFound; ++nTrack) { + Track* pTrack = pSequence->getTrack(nTrack); + for (uint32_t nPattern = 0; nPattern < pTrack->getPatterns() && !bFound; ++nPattern) { + if (pTrack->getPatternByIndex(nPattern) == pPattern) + bFound = true; + } + if (bFound) { + pTrack->setModified(); + pSequence->setModified(); + } + } + } + } } - return m_mBanks[bank][sequence]; } -bool SequenceManager::addPattern(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position, uint32_t pattern, bool force) { - Sequence* pSequence = getSequence(bank, sequence); - Track* pTrack = pSequence->getTrack(track); +Sequence* SequenceManager::getSequence(uint8_t scene, uint8_t phrase, uint8_t sequence) { + if (scene >= m_vScenes.size()) + return nullptr; + auto& vPhrases = m_vScenes[scene]; + if (sequence == PHRASE_CHANNEL && phrase < vPhrases.size()) + return vPhrases[phrase]; + if (phrase >= vPhrases.size() || sequence >= 32) + return nullptr; + return vPhrases[phrase]->m_aChildSequences[sequence]; +} + +bool SequenceManager::addPattern(Sequence* pSequence, uint32_t track, uint32_t position, uint32_t pattern, bool force) { + Track* pTrack = pSequence->getTrack(track); if (!pTrack) return false; - m_mPatterns[pattern]; // Ensure pattern exists and won't move in memory before accessing by pointer - bool bUpdated = pTrack->addPattern(position, &(m_mPatterns[pattern]), force); - updateSequenceLength(bank, sequence); + bool bUpdated = pTrack->addPattern(position, getPattern(pattern), force); + pSequence->updateLength(); return bUpdated; } -void SequenceManager::removePattern(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position) { - Sequence* pSequence = getSequence(bank, sequence); - Track* pTrack = pSequence->getTrack(track); +void SequenceManager::removePattern(Sequence* pSequence, uint32_t track, uint32_t position) { + if (!pSequence) + return; + Track* pTrack = pSequence->getTrack(track); if (!pTrack) return; pTrack->removePattern(position); - updateSequenceLength(bank, sequence); + pSequence->updateLength(); } -void SequenceManager::updateSequenceLength(uint8_t bank, uint8_t sequence) { getSequence(bank, sequence)->updateLength(); } - void SequenceManager::updateAllSequenceLengths() { - for (auto itBank = m_mBanks.begin(); itBank != m_mBanks.end(); ++itBank) - for (auto itSeq = itBank->second.begin(); itSeq != itBank->second.end(); ++itSeq) - (*itSeq)->updateLength(); + for (auto scene: m_vScenes) { + for (auto phrase: scene) { + // Update all sequences in phrase + for (uint8_t nSeq = 0; nSeq < 16; ++nSeq) { + Sequence* pSequence = phrase->m_aChildSequences[nSeq]; + if (pSequence) + pSequence->updateLength(); + } + } + } } -size_t SequenceManager::clock(std::pair timeinfo, std::multimap* pSchedule, bool bSync) { - /** Get events scheduled for next step from all tracks in each playing sequence. +uint8_t SequenceManager::clock(uint32_t nTime, std::multimap* pSchedule, bool bSync) { + /** Get events scheduled for next tick from all tracks in each playing sequence. Populate schedule with start, end and interpolated events */ - uint32_t nTime = timeinfo.first; - uint32_t nSamplesPerClock = timeinfo.second; - for (auto it = m_vPlayingSequences.begin(); it != m_vPlayingSequences.end();) { - Sequence* pSequence = getSequence(it->first, it->second); - if (pSequence->getPlayState() == STOPPED) { - it = m_vPlayingSequences.erase(it); - continue; + + // Clock ticks from start of bar + static uint32_t barPos = 0; + if (bSync) barPos = 0; + else barPos++; + + uint8_t nResult = 0; // Summary of playing sequences (0:None, 1:Starting, 2:Playing/stopping) + size_t nSequence = 0; + while (nSequence < m_vPlayingSequences.size()) { + Sequence* pSequence = m_vPlayingSequences[nSequence]; + uint8_t nGroup = pSequence->getGroup(); + uint32_t nPlayState = pSequence->getPlayState(); + bool bIsClippy = nGroup > 15 && nGroup < 32; + // Audio clip sequence + if (bIsClippy) { + uint8_t nChannel = nGroup - 16; + uint8_t nPhrase = pSequence->getPhrase(); + uint8_t nNote = nPhrase + 1; + if (nPlayState == STARTING && bSync) { + // Start playing clip at bar sync + nPlayState = PLAYING; + pSchedule->insert(std::pair(nTime, new SEQ_EVENT{nTime, 0xfe, uint8_t(MIDI_NOTE_ON | nChannel), nNote, 1})); + pSequence->setPlayState(PLAYING); + } else if (nPlayState == PLAYING) { + uint32_t nPos = pSequence->getPlayPosition() + 1; + if (nPos >= pSequence->getLength()) { + nPos = 0; + uint8_t nCount = pSequence->getPlayed() + 1; + if (nCount >= pSequence->getRepeat()) { + // End of repeats... + if (pSequence->getFollowSequence() == pSequence) + pSchedule->insert(std::pair(nTime, new SEQ_EVENT{nTime, 0xfe, uint8_t(MIDI_NOTE_ON | nChannel), nNote, 2})); + pSequence->setPlayed(0); + } else { + // Triggering repeat + pSequence->setPlayed(nCount); + pSchedule->insert(std::pair(nTime, new SEQ_EVENT{nTime, 0xfe, uint8_t(MIDI_NOTE_ON | nChannel), nNote, 3})); + } + } + pSequence->setPlayPosition(nPos); + } else if (bSync &&(nPlayState == STOPPING || nPlayState == STOPPING_SYNC)) { + // Stop clip + pSequence->setPlayState(STOPPED); + pSequence->setPlayed(0); + pSequence->setPlayPosition(0); + nPlayState = STOPPED; + } } - uint8_t nEventType = pSequence->clock(nTime, bSync, nSamplesPerClock); - if (nEventType & 1) { - // A step event - while (SEQ_EVENT* pEvent = pSequence->getEvent()) { - uint32_t nEventTime = pEvent->time; - MIDI_MESSAGE* pNewEvent = new MIDI_MESSAGE(pEvent->msg); - pSchedule->insert(std::pair(nEventTime, pNewEvent)); - // fprintf(stderr, "Clock time: %u Scheduling event 0x%x 0x%x 0x%x with time %u at %u framesPerClock: %f\n", nTime, pEvent->msg.command, - // pEvent->msg.value1, pEvent->msg.value2, pEvent->time, nEventTime, dSamplesPerClock); + // Step or phrase sequence + else if (nPlayState != STOPPED && nPlayState != CHILD_PLAYING) { + uint8_t nEventType = pSequence->clock(nTime, bSync, m_nTimeSig); + + if (nEventType & CLOCK_TRIG_MIDI) { + // A step event so iterate all step events starting on this tick + while (SEQ_EVENT* pEvent = pSequence->getEvent()) + pSchedule->insert(std::pair(pEvent->time, new SEQ_EVENT(*pEvent))); + } + if (nEventType & CLOCK_TRIG_TEMPO) { + // Tempo change + float tempo = pSequence->getTempo(); + m_bTempoChanged |= (m_fTempo != tempo); + m_fTempo = tempo; + } + if (nEventType & CLOCK_TRIG_TIMESIG) { + // Time signature change + uint8_t nTimeSig = pSequence->getTimeSig(); + if (nTimeSig) { + m_bTimeSigChanged |= (m_nTimeSig != nTimeSig); + m_nTimeSig = nTimeSig; + } + } + if (nEventType & CLOCK_TRIG_PHRASE) { + // Phrase start or re-trigger + if (pSequence->getPlayState() == PLAYING) { + for (uint8_t nChild = 0; nChild < 32; ++nChild) { + Sequence* pChildSeq = pSequence->m_aChildSequences[nChild]; + if (pChildSeq && pChildSeq->getRepeat() && pChildSeq->getPlayState() != PLAYING) { + // Schedule clippy child trigger MIDI event + uint8_t nChildGroup = pChildSeq->getGroup(); + if (nChildGroup > 15) { + uint8_t nChildChan = nChildGroup - 16; + uint8_t nChildNote = pSequence->getPhrase() + 1; + pSchedule->insert(std::pair(nTime, new SEQ_EVENT{nTime, 0xfe, uint8_t(MIDI_NOTE_ON | nChildChan), nChildNote, 1})); + } + // Set child sequence to play + setPlayState(pChildSeq, PLAYING); + } + } + } + } + if (nEventType & CLOCK_TRIG_SEQEND) { + // Reached end of sequence repeats + Sequence* pFollowSequence = pSequence->getFollowSequence(); + if (pFollowSequence && pFollowSequence->getRepeat()) + setPlayState(pFollowSequence, PLAYING); } } - if (nEventType & 2) { - // Change of state - // uint8_t nTrigger = getTriggerNote(it->first, it->second); - // It's currently polled from python + + // Stopped sequence + if (nPlayState == STOPPED || nPlayState == CHILD_PLAYING) { + if (nGroup < 33) + m_aGroupProgress[nGroup] = 0; + + // Stop clippy if no other clippy sequences in same group are running + if (bIsClippy && nPlayState == STOPPED) { + bool bStopClippy = true; + for (auto seq: m_vPlayingSequences) { + if (seq != pSequence && seq->getGroup() == nGroup) { + bStopClippy = false; + break; + } + } + if (bStopClippy) { + // Send clippy stop event + pSchedule->insert(std::pair(nTime, new SEQ_EVENT{nTime, 0xfe, uint8_t(MIDI_NOTE_ON | nGroup), 0, 1})); + } + } + m_vPlayingSequences.erase(m_vPlayingSequences.begin() + nSequence); + continue; } - ++it; + + if (pSequence->getPlayState() & 0x01) { + if (nGroup < 32 && pSequence->getLength()) + m_aGroupProgress[nGroup] = (100 * pSequence->getPlayPosition() / pSequence->getLength()); + else if (nGroup == 32) { + uint8_t nTimeSig = pSequence->getTimeSig(); + if (nTimeSig) + m_aGroupProgress[32] = (100 * barPos / (nTimeSig * PPQN_INTERNAL)); + else + m_aGroupProgress[32] = (100 * barPos / (m_nTimeSig * PPQN_INTERNAL)); + } + } + nResult |= (pSequence->getPlayState() & 0x3); + ++nSequence; } - return m_vPlayingSequences.size(); + + return nResult; } -void SequenceManager::setSequencePlayState(uint8_t bank, uint8_t sequence, uint8_t state) { - Sequence* pSequence = getSequence(bank, sequence); - if (state == STARTING || state == PLAYING || state == RESTARTING) { +void SequenceManager::setPlayState(Sequence* pSequence, uint8_t state) { + if (!pSequence) + return; + if (state == STARTING || state == PLAYING) { bool bAddToList = true; // Stop other sequences in same group + size_t nInsert = 0; for (auto it = m_vPlayingSequences.begin(); it != m_vPlayingSequences.end(); ++it) { - Sequence* pPlayingSequence = getSequence(it->first, it->second); - if (pPlayingSequence == pSequence) + Sequence* pPlayingSequence = *it; + if (pSequence == pPlayingSequence) bAddToList = false; else if (pPlayingSequence->getGroup() == pSequence->getGroup()) { - if (pPlayingSequence->getPlayState() == STARTING || pPlayingSequence->getPlayState() == RESTARTING) + if (pPlayingSequence->getPlayState() == STARTING) pPlayingSequence->setPlayState(STOPPED); - else if (pPlayingSequence->getPlayState() != STOPPED) - pPlayingSequence->setPlayState(STOPPING_SYNC); + else if (pPlayingSequence->getPlayState() != STOPPED) { + pPlayingSequence->setPlayState(state == STARTING?STOPPING:STOPPED); + } + if (pPlayingSequence->isPhraseLauncher()) + ++nInsert; } } - if (bAddToList) - m_vPlayingSequences.push_back(std::pair(bank, sequence)); + if (bAddToList) { + // Need phrase launchers before sequences to avoid a sequence playing its first event before a follow action stops that sequence + if (pSequence->isPhraseLauncher()) + m_vPlayingSequences.insert(m_vPlayingSequences.begin() + nInsert, pSequence); + else + m_vPlayingSequences.push_back(pSequence); + } } pSequence->setPlayState(state); + + // Start child sequences + if (state == STARTING) { + for (auto pChildSequence: pSequence->m_aChildSequences) { + if (pChildSequence && pChildSequence->getRepeat()) { + pChildSequence->setPlayState(STARTING); + } + } + } } -uint8_t SequenceManager::getTriggerNote(uint8_t bank, uint8_t sequence) { - uint16_t nValue = (bank << 8) | sequence; +void SequenceManager::stopGroup(uint8_t group) { + std::vector vSeq; + for (auto pSequence: m_vPlayingSequences) + if (pSequence->getGroup() == group) + vSeq.push_back(pSequence); + for (auto pSequence: vSeq) + setPlayState(pSequence, STOPPED); +} + +uint8_t SequenceManager::getTriggerNote(uint32_t phraseSeq) { for (auto it = m_mTriggers.begin(); it != m_mTriggers.end(); ++it) - if (it->second == nValue) + if (it->second == phraseSeq) return it->first; return 0xFF; } -void SequenceManager::setTriggerNote(uint8_t bank, uint8_t sequence, uint8_t note) { - m_mTriggers.erase(getTriggerNote(bank, sequence)); +void SequenceManager::setTriggerNote(uint16_t phraseSeq, uint8_t note) { + m_mTriggers.erase(getTriggerNote(phraseSeq)); if (note < 128) - m_mTriggers[note] = (bank << 8) | sequence; + m_mTriggers[note] = phraseSeq; } -uint8_t SequenceManager::getTriggerChannel() { return m_nTriggerChannel; } +uint8_t SequenceManager::getTriggerChannel() { + return m_nTriggerChannel; +} -void SequenceManager::setTriggerChannel(uint8_t channel) { m_nTriggerChannel = channel; } +void SequenceManager::setTriggerChannel(uint8_t channel) { + m_nTriggerChannel = channel; +} -uint8_t SequenceManager::getTriggerDevice() { return m_nTriggerDevice; } +uint8_t SequenceManager::getTriggerDevice() { + return m_nTriggerDevice; +} -void SequenceManager::setTriggerDevice(uint8_t idev) { m_nTriggerDevice = idev; } +void SequenceManager::setTriggerDevice(uint8_t idev) { + m_nTriggerDevice = idev; +} -uint16_t SequenceManager::getTriggerSequence(uint8_t note) { +uint32_t SequenceManager::getTriggerSequence(uint8_t note) { auto it = m_mTriggers.find(note); if (it != m_mTriggers.end()) return it->second; @@ -237,81 +424,311 @@ uint16_t SequenceManager::getTriggerSequence(uint8_t note) { size_t SequenceManager::getPlayingSequencesCount() { return m_vPlayingSequences.size(); } void SequenceManager::stop() { - for (auto it = m_vPlayingSequences.begin(); it != m_vPlayingSequences.end(); ++it) - getSequence(it->first, it->second)->setPlayState(STOPPED); + for (auto pSequence: m_vPlayingSequences) { + pSequence->setPlayState(STOPPED); + } m_vPlayingSequences.clear(); } -void SequenceManager::cleanPatterns() { - // Create copy of patterns map - std::map mPatterns; - for (auto it = m_mPatterns.begin(); it != m_mPatterns.end(); ++it) - mPatterns[it->first] = &(it->second); - - // Remove all patterns that are used by tracks - for (auto itBank = m_mBanks.begin(); itBank != m_mBanks.end(); ++itBank) { - for (auto itSeq = itBank->second.begin(); itSeq != itBank->second.end(); ++itSeq) { - uint32_t nTrack = 0; - while (Track* pTrack = (*itSeq)->getTrack(nTrack++)) { - uint32_t nIndex = 0; - while (Pattern* pPattern = pTrack->getPatternByIndex(nIndex++)) - mPatterns.erase(getPatternIndex(pPattern)); +bool SequenceManager::isTempoChanged() { return m_bTempoChanged; } + +float SequenceManager::getTempo(bool clear) { + if (clear) + m_bTempoChanged = false; + return m_fTempo; +} + +void SequenceManager::setTempo(float tempo) { + if (tempo > 10.0) + m_fTempo = tempo; +} + +bool SequenceManager::isTimeSigChanged() { return m_bTimeSigChanged; } + +uint8_t SequenceManager::getTimeSig(bool clear) { + if (clear) + m_bTimeSigChanged = false; + return m_nTimeSig; +} + +void SequenceManager::setTimeSig(uint8_t sig) { + m_bTimeSigChanged |= (m_nTimeSig != sig); + m_nTimeSig = sig; +} + +uint8_t SequenceManager::getDefaultTimeSig() { + return m_nDefaultTimeSig; +} + +void SequenceManager::setDefaultTimeSig(uint8_t bpb) { + m_nDefaultTimeSig = bpb; + // Change timesig on empty phrases + for (auto& scene: m_vScenes) { + for (auto& phrase: scene) { + if (phrase->isPhraseEmpty()) + if (phrase->setTimeSig(bpb)) + updateAllSequenceLengths(); + } + } +} + +uint8_t* SequenceManager::getProgress() { + return m_aGroupProgress; +} + +void SequenceManager::enableChannel(uint8_t channel, bool enable) { + if (channel >= 32) + return; + m_bEnabled[channel] = enable; + // Configure (enable/disable) sequences in the channel + for (uint8_t nScene = 0; nScene < m_vScenes.size(); ++nScene) { + for (uint8_t nPhrase = 0; nPhrase < m_vScenes[nScene].size(); ++nPhrase) { + Sequence* pSequence = getSequence(nScene, nPhrase, channel); + if (pSequence) { + pSequence->setRepeat(enable ? 1 : 0); + setPlayState(pSequence, STOPPED); } } } +} + +bool SequenceManager::isChannelEnabled(uint8_t channel) { + if (channel < 32) + return m_bEnabled[channel]; + return false; +} + +// Phrase handling + +uint8_t SequenceManager::getNumPhrases(uint8_t scene) { + return m_vScenes[scene].size(); +} + +void SequenceManager::refreshPhrases(uint8_t scene) { + for (uint8_t phrase = 0; phrase < m_vScenes[scene].size(); ++phrase) { + Sequence* pPhraseSeq = m_vScenes[scene][phrase]; + pPhraseSeq->setPhrase(phrase); + for (auto pSequence: pPhraseSeq->m_aChildSequences) { + if (pSequence) + pSequence->setPhrase(phrase); + } + } +} - // Remove patterns in main map that are in search map and empty - for (auto it = mPatterns.begin(); it != mPatterns.end(); ++it) { - if (it->second->getEvents() == 0) - m_mPatterns.erase(it->first); +Sequence* SequenceManager::insertPhrase(uint8_t scene, uint8_t phrase) { + for (uint8_t i = m_vScenes.size(); i <= scene; ++i) + m_vScenes.emplace_back(); // Create missing scenes + auto& vPhrases = m_vScenes[scene]; + Sequence* pPhrase = new Sequence(nullptr); + if (!pPhrase) + return nullptr; + if (phrase >= vPhrases.size()) { + phrase = vPhrases.size(); + vPhrases.push_back(pPhrase); + } else { + vPhrases.insert(vPhrases.begin() + phrase, pPhrase); + } + std::string s; + //s = 'A' + phrase; + //pPhrase->setName(s); + pPhrase->setGroup(32); + pPhrase->setRepeat(255); + pPhrase->setTimeSig(m_nDefaultTimeSig); + for (uint8_t chan = 0; chan < 32; ++chan) { + Sequence* pSequence = new Sequence(pPhrase); + pSequence->setGroup(chan); + //pSequence->setName(s + std::to_string(chan + 1)); + if (chan < 16) { + Track* pTrack = pSequence->getTrack(0); + pTrack->setChannel(chan); + uint32_t nPattern = createPattern(m_nDefaultTimeSig); + addPattern(pSequence, 0, 0, nPattern); + } + pPhrase->m_aChildSequences[chan] = pSequence; + setFollowAction(scene, pSequence, FOLLOW_ACTION_RELATIVE, 0); // Loop + if (m_bEnabled[chan]) + pSequence->setRepeat(1); } + refreshPhrases(scene); + return pPhrase; } -void SequenceManager::setSequencesInBank(uint8_t bank, uint8_t sequences) { - // Remove excessive sequences - size_t nSize = m_mBanks[bank].size(); - while (nSize > sequences) { - setSequencePlayState(bank, nSize - 1, STOPPED); - delete m_mBanks[bank].back(); - m_mBanks[bank].pop_back(); - nSize = m_mBanks[bank].size(); +// TODO: Could be implemented as assignation operator / constructor in Sequence class? +Sequence* SequenceManager::duplicatePhrase(uint8_t scene, uint8_t phrase) { + for (uint8_t i = m_vScenes.size(); i <= scene; ++i) + m_vScenes.emplace_back(); // Create missing scenes + auto& vPhrases = m_vScenes[scene]; + Sequence* pSrcPhrase = vPhrases[phrase]; + Sequence* pPhrase = new Sequence(nullptr); + if (!pPhrase) + return nullptr; + if (phrase + 1 >= vPhrases.size()) { + vPhrases.push_back(pPhrase); + } else { + vPhrases.insert(vPhrases.begin() + phrase + 1, pPhrase); } - cleanPatterns(); - // Add required sequences - if (sequences == 0) - getSequence(bank, sequences - 1); -} - -uint32_t SequenceManager::getSequencesInBank(uint32_t bank) { return m_mBanks[bank].size(); } - -bool SequenceManager::moveSequence(uint8_t bank, uint8_t sequence, uint8_t position) { - if (sequence >= getSequencesInBank(bank)) - setSequencesInBank(bank, sequence + 1); - if (position >= getSequencesInBank(bank)) - setSequencesInBank(bank, position + 1); - Sequence* pSequence = getSequence(bank, sequence); // Store sequence we want to move - if (position < sequence) { - for (size_t nIndex = sequence; nIndex > position; --nIndex) - m_mBanks[bank][nIndex] = m_mBanks[bank][nIndex - 1]; - m_mBanks[bank][position] = pSequence; - } else if (position > sequence) { - for (size_t nIndex = sequence; nIndex < position; ++nIndex) - m_mBanks[bank][nIndex] = m_mBanks[bank][nIndex + 1]; - m_mBanks[bank][position] = pSequence; + pPhrase->setName(pSrcPhrase->getName()); + pPhrase->setGroup(32); + pPhrase->setRepeat(pSrcPhrase->getRepeat()); + setFollowAction(scene, pPhrase, pSrcPhrase->getFollowAction(), pSrcPhrase->getFollowParam()); + pPhrase->setTimeSig(pSrcPhrase->getTimeSig()); + pPhrase->setTempo(pSrcPhrase->getTempo()); + for (uint8_t chan = 0; chan < 32; ++chan) { + Sequence* pSrcSeq = pSrcPhrase->m_aChildSequences[chan]; + Sequence* pSequence = new Sequence(pPhrase); + pPhrase->m_aChildSequences[chan] = pSequence; + pSequence->setGroup(chan); + pSequence->setName(pSrcSeq->getName()); + pSequence->setRepeat(pSrcSeq->getRepeat()); + setFollowAction(scene, pSequence, pSrcSeq->getFollowAction(), pSrcSeq->getFollowParam()); + if (chan < 16) { + Track* pTrack = pSequence->getTrack(0); + pTrack->setChannel(chan); + uint32_t nPattern = createPattern(m_nDefaultTimeSig); + // Copy pattern from source sequence + *(getPattern(nPattern)) = *(pSrcSeq->getTrack(0)->getPatternByIndex(0)); + // Add pattern + addPattern(pSequence, 0, 0, nPattern); + } } - return true; + refreshPhrases(scene); + return pPhrase; } -void SequenceManager::insertSequence(uint8_t bank, uint8_t sequence) { - m_mBanks[bank].insert(m_mBanks[bank].begin() + sequence, new Sequence()); - addPattern(bank, sequence, 0, 0, createPattern(), false); +void SequenceManager::removePhrase(uint8_t scene, uint8_t phrase) { + if (scene >= m_vScenes.size()) + return; + auto& vPhrases = m_vScenes[scene]; + if (phrase >= vPhrases.size()) + return; + + Sequence* pPhrase = vPhrases[phrase]; + // Iterate each sequence in phrase + for (uint8_t nSeq = 0; nSeq < 32; ++nSeq) { + Sequence* pChildSeq = pPhrase->m_aChildSequences[nSeq]; + if (!pChildSeq) + continue; + // Iterate each playing sequence + for (auto it_playing = m_vPlayingSequences.begin(); it_playing != m_vPlayingSequences.end(); ++it_playing) { + if (pChildSeq == *it_playing) { + // Remove from playing sequences + m_vPlayingSequences.erase(it_playing); + break; + } + } + delete pChildSeq; + pPhrase->m_aChildSequences[nSeq] = nullptr; + } + + // Delete the pattern used by phrase launcher + Track* pTrack = pPhrase->getTrack(0); + if (pTrack) { + Pattern* pPattern = pTrack->getPattern(0); + if (pPattern) { + deletePattern(getPatternIndex(pPattern)); + } + } + + // Delete phrase launcher sequence + delete pPhrase; + vPhrases.erase(vPhrases.begin() + phrase); + + // Refresh follow actions + for (auto& pPhrase2: vPhrases) + setFollowAction(scene, pPhrase2, pPhrase2->getFollowAction(), pPhrase2->getFollowParam()); + refreshPhrases(scene); } -void SequenceManager::removeSequence(uint8_t bank, uint8_t sequence) { - if (sequence < m_mBanks[bank].size()) { - delete (m_mBanks[bank][sequence]); - m_mBanks[bank].erase(m_mBanks[bank].begin() + sequence); +void SequenceManager::swapPhrase(uint8_t scene, uint8_t phrase1, uint8_t phrase2) { + if (scene >= m_vScenes.size()) + return; + auto& vPhrases = m_vScenes[scene]; + if (phrase1 == phrase2 || phrase1 >= vPhrases.size() || phrase2 >= vPhrases.size()) + return; + std::iter_swap(vPhrases.begin() + phrase1, vPhrases.begin() + phrase2); + // Update follow actions for all phrases in this scene to handle jumps into and out of these phrases + for (auto& phraseSeq: m_vScenes[scene]) + setFollowAction(scene, phraseSeq, phraseSeq->getFollowAction(), phraseSeq->getFollowParam()); + refreshPhrases(scene); +} + +void SequenceManager::setPhraseTimeSig(uint8_t scene, uint8_t phrase, uint8_t bpb) { + if (scene >= m_vScenes.size()) + return; + auto& vPhrases = m_vScenes[scene]; + if (phrase >= vPhrases.size()) + return; + bool bLenChange = false; + // Change timesig of selected phrase + bLenChange |= vPhrases[phrase]->setTimeSig(bpb); + // Change timesig on next consecutive empty phrases + while (++phrase < vPhrases.size()) { + if (vPhrases[phrase]->isPhraseEmpty()) + bLenChange |= vPhrases[phrase]->setTimeSig(bpb); + else + break; } + // Update lengths if needed + if (bLenChange) + updateAllSequenceLengths(); +} + +uint8_t SequenceManager::getPhraseTimeSig(uint8_t scene, uint8_t phrase) { + if (scene >= m_vScenes.size()) + return 0; + auto& vPhrases = m_vScenes[scene]; + if (phrase >= vPhrases.size()) + return 0; + + Sequence* pPhrase = vPhrases[phrase]; + return pPhrase->getTimeSig(); } -uint32_t SequenceManager::getBanks() { return m_mBanks.size(); } +bool SequenceManager::isPhraseEmpty(uint8_t scene, uint8_t phrase) { + if (scene >= m_vScenes.size()) + return 0; + auto& vPhrases = m_vScenes[scene]; + if (phrase >= vPhrases.size()) + return 0; + + return vPhrases[phrase]->isPhraseEmpty(); +} + +bool SequenceManager::setFollowAction(uint8_t scene, Sequence* sequence, uint8_t action, int16_t param) { + if (sequence && scene < m_vScenes.size()) { + auto& vPhrases = m_vScenes[scene]; + switch (action) { + case FOLLOW_ACTION_ABSOLUTE: + if (param < 0 || param > vPhrases.size()) + return false; + sequence->setFollowSequence(vPhrases[param], action, param); + return true; + break; + case FOLLOW_ACTION_RELATIVE: + if (param == 0) { + // Loop + sequence->setFollowSequence(sequence, action, param); + return true; + } else { + // Find index of sequence - this should already be known by caller!!! + for (uint32_t i = 0; i < vPhrases.size(); ++i) { + if (vPhrases[i] == sequence) { + int16_t offset = param + i; + if (offset >= 0 && offset < vPhrases.size()) { + sequence->setFollowSequence(vPhrases[offset], action, param); + return true; + } else { + // Attempt to select non-existing phrase so set to none. + sequence->setFollowSequence(nullptr, 0, 0); + } + break; + } + } + } + break; + default: + sequence->setFollowSequence(nullptr, 0, 0); + } + } + return false; +} diff --git a/zynlibs/zynseq/sequencemanager.h b/zynlibs/zynseq/sequencemanager.h index e1e411c04..a7bfe47f8 100644 --- a/zynlibs/zynseq/sequencemanager.h +++ b/zynlibs/zynseq/sequencemanager.h @@ -1,244 +1,355 @@ +/* Declares SequenceManager class managing collection of sequences + * + * Copyright (c) 2020-2025 Brian Walton + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. +*/ + #pragma once #include "pattern.h" #include "sequence.h" #include "track.h" #include -#define DEFAULT_TRACK_COUNT 4 /** SequenceManager class provides creation, recall, update and delete of patterns which other modules can subseqnetly use. It manages persistent (disk) * storage. SequenceManager is implemented as a singleton ensuring a single instance is available to all callers. - */ +*/ class SequenceManager { public: /** @brief Instantiate sequence manager object - */ + */ SequenceManager(); /** @brief Initialise all data - */ + */ void init(); - /** @brief Reset banks map - */ - void resetBanks(); + /** @brief Select a scene + @param scene Index of scene + @retval bool True if new scene created + */ + bool setScene(uint8_t scene); + + /** @brief Get currently selected scene + @retval uint8_t Index of scene + */ + uint8_t getScene(); + + /** @brief Get quantity of scenes + @retval uint8_t Quantity of scenes + */ + uint8_t getNumScenes(); + + /** @brief Remove a scene + @param scene Inde of scene + @note Subsequenct scenes are renumbered. If selected scene is higher, select scene zero. + */ + void removeScene(uint8_t scene); /** @brief Get pointer to a pattern - * @param index Index of pattern to retrieve - * @retval Pattern* Pointer to pattern - * @note If pattern does not exist a new, default, empty pattern is created - */ + @param index Index of pattern to retrieve + @retval Pattern* Pointer to pattern + @note If pattern does not exist a new, default, empty pattern is created + */ Pattern* getPattern(uint32_t index); /** @brief Get the index of a pattern - * @param pattern Pointer to pattern - * @retval uint32_t Index of pattern or -1 if not found - */ + @param pattern Pointer to pattern + @retval uint32_t Index of pattern or -1 if not found + */ uint32_t getPatternIndex(Pattern* pattern); /** @brief Get next populated pattern after current pattern - * @param pattern Index of current pattern - * @retval uint32_t Index of pattern - */ + @param pattern Index of current pattern + @retval uint32_t Index of pattern + */ uint32_t getNextPattern(uint32_t pattern); /** @brief Create new pattern - * @retval uint32_t Index of new pattern - * @note Use getPattern to retrieve pointer to pattern - */ - uint32_t createPattern(); + @param Number of beats in pattern. + @retval uint32_t Index of new pattern + @note Use getPattern to retrieve pointer to pattern + */ + uint32_t createPattern(uint32_t beats = DEFAULT_BPB); /** @brief Delete pattern - * @param index Index of the pattern to delete - */ + @param index Index of the pattern to delete + */ void deletePattern(uint32_t index); /** @brief Copy pattern - * @param source Index of pattern to copy from - * @param destination Index of pattern to populate - */ + @param source Index of pattern to copy from + @param destination Index of pattern to populate + */ void copyPattern(uint32_t source, uint32_t destination); - /** @brief Replace pattern - * @param index Pattern index - * @param pattern Pointer to new pattern - */ - void replacePattern(uint32_t index, Pattern* pattern); - - /** @brief Update sequence lengths in current bank - * @param bank Index of bank - * @param sequence Index of sequence - */ - void updateSequenceLength(uint8_t bank, uint8_t sequence); + /** @brief Flag pattern as modified - also sets flags in relevant sequences and tracks + @param pPattern Pointer to pattern + */ + void setPatternModified(Pattern* pPattern); /** @brief Update all sequence lengths - * @note Blunt tool to update each sequence after any pattern length changes - */ + @note Blunt tool to update each sequence after any pattern length changes + */ void updateAllSequenceLengths(); /** @brief Handle clock - * @param timeinfo Pair: Offset since JACK epoch for start of next period, duration of clock cycle in frames - * @param pSchedule Pointer to the schedule to populate with events - * @param bSync True indicates a sync pulse - * @param dSamplesPerClock Quantity of samples in each clock cycle - * @retval size_t Quantity of playing sequences - */ - size_t clock(std::pair timeinfo, std::multimap* pSchedule, bool bSync); + @param nTime Offset since sequence tick epoch for this tick + @param pSchedule Pointer to the schedule to populate with events + @param bSync True indicates a sync pulse + @retval uint8_t Bitwise flag indication sumary of playing sequences. [1: Playing 2:Starting] + */ + uint8_t clock(uint32_t nTime, std::multimap* pSchedule, bool bSync); /** @brief Get pointer to sequence - * @param bank Index of bank containing sequence - * @param offset Index (offset) of sequence within bank - * @retval Sequence* Pointer to sequence or NULL if invalid offset - * @note Creates new bank and sequence if not existing - */ - Sequence* getSequence(uint8_t bank, uint8_t sequence); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence within phrase or PHRASE_CHANNEL to get phrase launcher's sequence + @retval Sequence* Pointer to sequence or nullptr if not existing + */ + Sequence* getSequence(uint8_t scene, uint8_t phrase, uint8_t sequence); /** @brief Add pattern to sequence - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - * @param position Quantity of clock cycles from start of track at which to add pattern - * @param pattern Index of pattern - * @param force True to remove overlapping patterns, false to fail if overlapping patterns - * @retval True if pattern inserted - */ - bool addPattern(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position, uint32_t pattern, bool force); + @param pSequence Pointer to sequence + @param track Index of track + @param position Quantity of clock cycles from start of track at which to add pattern + @param pattern Index of pattern + @param force True to remove overlapping patterns, false to fail if overlapping patterns + @retval True if pattern inserted + */ + bool addPattern(Sequence* pSequence, uint32_t track, uint32_t position, uint32_t pattern, bool force=false); /** @brief Remove pattern from track - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - * @param position Quantity of clock cycles from start of track from which to remove pattern - */ - void removePattern(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position); - - /** Set sequence play state - * @param bank Index of bank containing sequence - * @param offset Index (offset) of sequence within bank - * @param state Play state - * @note Stops other sequences in group - */ - void setSequencePlayState(uint8_t bank, uint8_t sequence, uint8_t state); + @param pSequence Pointer to sequence + @param track Index of track + @param position Quantity of clock cycles from start of track from which to remove pattern + */ + void removePattern(Sequence* pSequence, uint32_t track, uint32_t position); + + /** @brief Set sequence play state + @param pSequence Pointer to sequence + @param state Play state + @note Stops other sequences in group + */ + void setPlayState(Sequence* pSequence, uint8_t state); + + /** @brief Stop all sequences in group + @param group Group ID + */ + void stopGroup(uint8_t group); /** @brief Get MIDI note number used to trigger sequence - * @param bank Index of bank containing sequence - * @param offset Index (offset) of sequence within bank - * @retval uint8_t MIDI note number [0xFF for none] - */ - uint8_t getTriggerNote(uint8_t bank, uint8_t sequence); + @param phraseSeq phrase and sequence encoded into 32-bit word + @retval uint8_t MIDI note number [0xFF for none] + */ + uint8_t getTriggerNote(uint32_t phraseSeq); /** @brief Set MIDI note number used to trigger sequence - * @param bank Index of bank containing sequence - * @param offset Index (offset) of sequence within bank - * @param note MIDI note number [0xFF for none] - */ - void setTriggerNote(uint8_t bank, uint8_t sequence, uint8_t note); + @param phraseSeq Phrase and sequence encoded into 32-bit word + @param note MIDI note number [0xFF for none] + */ + void setTriggerNote(uint16_t phraseSeq, uint8_t note); /** @brief Get MIDI trigger channel - * @retval uint8_t MIDI channel - */ + @retval uint8_t MIDI channel + */ uint8_t getTriggerChannel(); /** @brief Set MIDI trigger channel - * @param channel MIDI channel [0..15 or other to disable MIDI trigger] - */ + @param channel MIDI channel [0..15 or other to disable MIDI trigger] + */ void setTriggerChannel(uint8_t channel); /** @brief Get MIDI trigger device - * @retval uint8_t MIDI device index - */ + @retval uint8_t MIDI device index + */ uint8_t getTriggerDevice(); /** @brief Set MIDI trigger device - * @param idev MIDI device index [0..15 or other to disable MIDI device] - */ + @param idev MIDI device index [0..15 or other to disable MIDI device] + */ void setTriggerDevice(uint8_t idev); /** @brief Get sequence triggered by MIDI note - * @param note MIDI note number - * @retval uint16_t Bank (MSB) and Sequence (LSB) or 0 if not configured - */ - uint16_t getTriggerSequence(uint8_t note); - - /** @brief Set the current bank - * @param bank Bank to select - */ - void setCurrentBank(uint32_t bank); - - /** @brief Get current bank - * @retval uint32_t Index of current bank - */ - uint32_t getCurrentBank(); + @param note MIDI note number + @retval uint32_t Phrase and sequence encoded into 32-bit word 0xffffffff if not set + */ + uint32_t getTriggerSequence(uint8_t note); /** @brief Get overall quantity of playing sequences - * @retval size_t Quantity of sequence staring, playing or stopping. Zero if all sequences are stopped - */ + @retval size_t Quantity of sequence staring, playing or stopping. Zero if all sequences are stopped + */ size_t getPlayingSequencesCount(); /** @brief Stop all collections / sequences - */ + */ void stop(); - /** @brief Remove all unused empty patterns - */ - void cleanPatterns(); - - /** @brief Set quantity of sequences in a bank - * @param bank Bank index - * @param sequences Quantity of sequences - * @note Sequences are created or destroyed as required - */ - void setSequencesInBank(uint8_t bank, uint8_t sequences); - - /** @brief Get quantity of sequences in a bank - * @param bank Index of bank - */ - uint32_t getSequencesInBank(uint32_t bank); - - /** @brief Move sequence (change order of sequences) - * @param bank Index of bank - * @param sequence Index of sequence to move - * @param position Index of sequence to move this sequence, e.g. 0 to insert as first sequence - * @note Sequences after insert point are moved up by one. Bank grows if sequence or position is higher than size of bank - * @retval bool True on success - */ - bool moveSequence(uint8_t bank, uint8_t sequence, uint8_t position); - - /** @brief Insert new sequence in bank - * @param bank Index of bank - * @param sequence Index at which to insert sequence , e.g. 0 to insert as first sequence - * @note Sequences after insert point are moved up by one. Bank grows if sequence is higher than size of bank - */ - void insertSequence(uint8_t bank, uint8_t sequence); - - /** @brief Remove sequence from bank - * @param bank Index of bank - * @param sequence Index of sequence to remove - * @note Sequences after remove point are moved down by one. Bank grows if sequence is higher than size of bank - */ - void removeSequence(uint8_t bank, uint8_t sequence); - - /** @brief Get quantity of banks - * @retval uint32_t Quantity of populated banks - */ - uint32_t getBanks(); - - private: + /** @brief Check if tempo has changed + @retval bool True if tempo has changed + */ + bool isTempoChanged(); + + /** @brief Get current tempo + @param clear True to clear current tempo changed flag (default: true) + @retval float Current tempo + */ + float getTempo(bool clear = true); + + /** @brief Set current tempo + @param tempo Tempo in bpm + */ + void setTempo(float tempo); + + /** @brief Check if time signature has changed + @retval bool True if time signature has changed + */ + bool isTimeSigChanged(); + + /** @brief Get current time signature (beats in bar) + @param clear True to clear current sig changed flag (default: false) + @retval uint8_t Current time signature + */ + uint8_t getTimeSig(bool clear = false); + + /** @brief Set current time signature (beats in bar) + @param sig Current time signature (in quarter notes) + */ + void setTimeSig(uint8_t sig); + + /** @brief Get default time signature (beats in bar) + @retval uint8_t Default time signature + */ + uint8_t getDefaultTimeSig(); + + /** @brief Set default time signature (beats in bar) + @param bpb default time signature (in quarter notes) + */ + void setDefaultTimeSig(uint8_t bpb); + + /** @brief Get playback progress percentage + @param uint8_t* Pointer to 33 element array containing progress as a percentage of sequence length + @note Element 32 (phrase launchers) is a percentage of the current time signature (beats per bar) + */ + uint8_t* getProgress(); + + /** @brief Enable a channel + @param channel MIDI channel + @param enable True to enable + */ + void enableChannel(uint8_t channel, bool enable); + + /** @brief Is channel enabled + @param channel MIDI channel + @retval bool True if channel is enabled + */ + bool isChannelEnabled(uint8_t channel); + + // Phrase handling + + /** @brief Get quantity of phrases + @param scene Index of scene + @retval uint8_t Quantity of phrases + */ + uint8_t getNumPhrases(uint8_t scene); + + /** @brief Add phrase + @param scene Index of scene + @param phrase Index of phrase to insert before + @retval Sequence* Pointer to the phrase sequence or nullptr on failure + */ + Sequence* insertPhrase(uint8_t scene, uint8_t phrase); + + /** @brief Duplicate phrase + @param scene Index of scene + @param phrase Index of phrase to duplicate + @retval Sequence* Pointer to the phrase sequence or nullptr on failure + */ + Sequence* duplicatePhrase(uint8_t scene, uint8_t phrase); + + /** @brief Remove phrase + @param scene Index of scene + @param phrase Index of phrase to remove + */ + void removePhrase(uint8_t scene, uint8_t phrase); + + /** @brief Swap position of two phrases + @param scene Index of scene + @param phrase1 Index of first phrase + @param phrase2 Index of second phrase + */ + void swapPhrase(uint8_t scene, uint8_t phrase1, uint8_t phrase2); + + /** @brief Set the time signature for a phrase + @param scene Index of scene + @param phrase Index of phrase + @param bpb Time signature in Beats per Bar + */ + void setPhraseTimeSig(uint8_t scene, uint8_t phrase, uint8_t bpb); + + /** @brief Set the time signature for a phrase + @param scene Index of scene + @param phrase Index of phrase + @retval uint8_t Phrase's time signature in Beats per Bar + */ + uint8_t getPhraseTimeSig(uint8_t scene, uint8_t phrase); + + /** @brief Is a phrase empty? + @param scene Index of scene + @param phrase Index of phrase + @retval bool True is phrase is empty => all its sequences are empty + */ + bool isPhraseEmpty(uint8_t scene, uint8_t phrase); + + /** @brief Set follow action + @param scene Index of scene + @param sequence Pointer to sequence + @param action Follow action @see FOLLOW_ACTION enum + @param param Parameter of action, e.g. offset + @retval bool True on success + */ + bool setFollowAction(uint8_t scene, Sequence* sequence, uint8_t action, int16_t param); + + + private: + int fileWrite32(uint32_t value, FILE* pFile); int fileWrite16(uint16_t value, FILE* pFile); int fileWrite8(uint8_t value, FILE* pFile); uint32_t fileRead32(FILE* pFile); uint16_t fileRead16(FILE* pFile); uint8_t fileRead8(FILE* pFile); - bool checkBlock(FILE* pFile, uint32_t nActualSize, uint32_t nExpectedSize); - - uint8_t m_nTriggerDevice = 0xFF; // MIDI device to receive sequence triggers (note-on) + void refreshPhrases(uint8_t scene); + + bool m_bTempoChanged = false; // True if tempo changed by sequence + float m_fTempo = DEFAULT_TEMPO; // Current tempo + bool m_bTimeSigChanged = false; // True if time signature changed by sequence + uint8_t m_nTimeSig = DEFAULT_BPB; // Current time signature in beats (1/4 notes) per bar + uint8_t m_nDefaultTimeSig = DEFAULT_BPB; // Default time signature in beats (1/4 notes) per bar + uint8_t m_nTriggerDevice = 0xFF; // MIDI device to receive sequence triggers (note-on) uint8_t m_nTriggerChannel = 0xFF; // MIDI channel to receive sequence triggers (note-on) + uint8_t m_aGroupProgress[33]; // Array of group playback progress percentage + uint8_t m_nBeatsPerBar = 4; // Time signature in beats + uint8_t m_bEnabled[32]; // Array indicating if channel is enabled + uint8_t m_nScene = 0; // Index of selected scene + std::vector> m_vScenes; // Vector of vectors of pointers to phrase sequences, indexed by scene. + // m_vScenes[scene][phrase] = pPhrase, a sequence with child sequences. // Note: Maps are used for patterns and sequences to allow addition and removal of sequences whilst maintaining consistent access to remaining instances - std::map m_mPatterns; // Map of patterns indexed by pattern number - std::vector> - m_vPlayingSequences; // Vector of pairs for currently playing sequences (used to optimise play control) - std::map m_mTriggers; // Map of bank<<8|sequence indexed by MIDI note triggers - std::map> m_mBanks; // Map of banks: vectors of pointers to sequences indexed by bank + std::map m_mPatterns; // Map of pattern pointers indexed by pattern number + std::vector m_vPlayingSequences; // Vector of pointers to currently playing sequences (used to optimise play control) + std::map m_mTriggers; // Map of phrase,sequence indexed by MIDI note triggers }; diff --git a/zynlibs/zynseq/test.py b/zynlibs/zynseq/test.py index 895653403..45f7bedc6 100755 --- a/zynlibs/zynseq/test.py +++ b/zynlibs/zynseq/test.py @@ -22,8 +22,8 @@ def testSave(): libzynseq.save(b"./test.zynseq") -def testSong(): - libzynseq.selectSong(1) +def testScene(): + libzynseq.selectScene(1) print("Get tracks (expect 0):", libzynseq.getTracks()) for track in range(1, 5): libzynseq.addTrack() diff --git a/zynlibs/zynseq/timebase.cpp b/zynlibs/zynseq/timebase.cpp index c90704b0c..1dec4594c 100644 --- a/zynlibs/zynseq/timebase.cpp +++ b/zynlibs/zynseq/timebase.cpp @@ -1,6 +1,6 @@ /* Defines Timebase class providing tempo / time signature map * - * Copyright (c) 2020 Brian Walton + * Copyright (c) 2020-2025 Brian Walton * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -27,17 +27,19 @@ Timebase::~Timebase() { delete *it; } -uint16_t Timebase::getTempo(uint16_t bar, uint16_t clock) { - uint16_t nValue = DEFAULT_TEMPO; +float Timebase::getTempo(uint16_t bar, uint16_t clock) { + float fValue = 0.0f; + if (bar < 1) + bar = 1; for (auto it = m_vEvents.begin(); it != m_vEvents.end(); ++it) { if ((*it)->type == TIMEBASE_TYPE_TEMPO && ((*it)->bar < bar || (*it)->bar == bar && (*it)->clock <= clock)) - nValue = (*it)->value; + fValue = (*it)->value / 100.0; } - return nValue; + return fValue; } -#include + uint16_t Timebase::getTimeSig(uint16_t bar, uint16_t clock) { - uint16_t nValue = 4; + uint16_t nValue = 0; for (auto it = m_vEvents.begin(); it != m_vEvents.end(); ++it) { if ((*it)->type == TIMEBASE_TYPE_TIMESIG && ((*it)->bar < bar || (*it)->bar == bar && (*it)->clock <= clock)) nValue = (*it)->value; @@ -57,10 +59,10 @@ void Timebase::addTimebaseEvent(uint16_t bar, uint16_t clock, uint16_t type, uin break; // Found point to insert } TimebaseEvent* pEvent = new TimebaseEvent; - pEvent->bar = bar; - pEvent->clock = clock; - pEvent->type = type; - pEvent->value = value; + pEvent->bar = bar; + pEvent->clock = clock; + pEvent->type = type; + pEvent->value = value; m_vEvents.insert(it, pEvent); } diff --git a/zynlibs/zynseq/timebase.h b/zynlibs/zynseq/timebase.h index eb0f4f111..4b250d3d7 100644 --- a/zynlibs/zynseq/timebase.h +++ b/zynlibs/zynseq/timebase.h @@ -1,6 +1,6 @@ /* Declares Timebase class providing tempo / time signature map * - * Copyright (c) 2020 Brian Walton + * Copyright (c) 2020-2025 Brian Walton * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. - */ +*/ #pragma once @@ -37,90 +37,90 @@ struct TimebaseEvent { }; /** Timebase class provides timebase event map - */ +*/ class Timebase { public: /** @brief Construct jack transport object - * @param client Pointer to jack client - */ + @param client Pointer to jack client + */ Timebase(); /** @brief Destruction called when object destroyed - */ + */ ~Timebase(); /** @brief Get tempo at specified time - * @param bar Bar at which to get tempo - * @param clock Clock cycle within bar at which to get tempo - * @retval uint16_t Tempo in beats per minute - */ - uint16_t getTempo(uint16_t bar, uint16_t clock); - - /** @brief Get t at specified time - * @param bar Bar at which to get time signature - * @param clock Clock cycle within bar at which to get time signature - * @retval uint16_t Time signature in beats per bar - */ + @param bar Bar at which to get tempo + @param clock Clock cycle within bar at which to get tempo + @retval float Tempo in beats per minute or 0.0 if no tempo in timebase + */ + float getTempo(uint16_t bar, uint16_t clock); + + /** @brief Get time signature at specified time + @param bar Bar at which to get time signature + @param clock Clock cycle within bar at which to get time signature + @retval uint16_t Time signature in beats per bar + */ uint16_t getTimeSig(uint16_t bar, uint16_t clock); /** @brief Add timebase event to map - * @param bar Bar within which event occurs - * @param clock Clock within bar at which event occurs - * @param type Event type [TIMEBASE_TYPE_TEMPO | TIMEBASE_TYPE_TIMESIG] - * @param value Event value - */ + @param bar Bar within which event occurs + @param clock Clock within bar at which event occurs + @param type Event type [TIMEBASE_TYPE_TEMPO | TIMEBASE_TYPE_TIMESIG] + @param value Event value + */ void addTimebaseEvent(uint16_t bar, uint16_t clock, uint16_t type, uint16_t value); /** @brief Remove timebase event from map - * @param bar Bar within which event occurs - * @param clock Clock within bar at which event occurs - * @param type Event type [TIMEBASE_TYPE_TEMPO | TIMEBASE_TYPE_TIMESIG] - */ + @param bar Bar within which event occurs + @param clock Clock within bar at which event occurs + @param type Event type [TIMEBASE_TYPE_TEMPO | TIMEBASE_TYPE_TIMESIG] + */ void removeTimebaseEvent(uint16_t bar, uint16_t clock, uint16_t type); /** @brief Get next timebase event - * @param bar Bar from which to search - * @param clock Clock within bar from which to search - * @param type Event type mask [TIMEBASE_TYPE_TEMPO | TIMEBASE_TYPE_TIMESIG | TIMEBASE_TYPE_ANY] - * @retval TimebaseEvent* Pointer to timebase event or NULL if none found - */ + @param bar Bar from which to search + @param clock Clock within bar from which to search + @param type Event type mask [TIMEBASE_TYPE_TEMPO | TIMEBASE_TYPE_TIMESIG | TIMEBASE_TYPE_ANY] + @retval TimebaseEvent* Pointer to timebase event or NULL if none found + */ TimebaseEvent* getNextTimebaseEvent(uint16_t bar, uint16_t clock, uint16_t type); /** @brief Get next timebase event - * @param pEvent Pointer to the event from which to search - * @retval TimebaseEvent* Pointer to next timebase event or NULL if none found - */ + @param pEvent Pointer to the event from which to search + @retval TimebaseEvent* Pointer to next timebase event or NULL if none found + */ TimebaseEvent* getNextTimebaseEvent(TimebaseEvent* pEvent); /** @brief Get previous timebase event - * @param bat Bar from which to search - * @param clock Clock within bar from which to search - * @param type Event type mask [TIMEBASE_TYPE_TEMPO | TIMEBASE_TYPE_TIMESIG | TIMEBASE_TYPE_ANY] - * @retval TimebaseEvent* Pointer to timebase event or NULL if none found - */ + @param bat Bar from which to search + @param clock Clock within bar from which to search + @param type Event type mask [TIMEBASE_TYPE_TEMPO | TIMEBASE_TYPE_TIMESIG | TIMEBASE_TYPE_ANY] + @retval TimebaseEvent* Pointer to timebase event or NULL if none found + */ TimebaseEvent* getPreviousTimebaseEvent(uint16_t bar, uint16_t clock, uint16_t type); /** @brief Get first timebase event - * @retval TimebaseEvent* Pointer to first timebase event or NULL if none found - */ + @retval TimebaseEvent* Pointer to first timebase event or NULL if none found + */ TimebaseEvent* getFirstTimebaseEvent(); /** @brief Get first event at specified time - * @param bar Bar within which event occurs - * @param clock Clock pulse within bar at which event occurs - * @retval TimebaseEvent* Pointer to the first timebase event at this time or NULL if none found - */ + @param bar Bar within which event occurs + @param clock Clock pulse within bar at which event occurs + @retval TimebaseEvent* Pointer to the first timebase event at this time or NULL if none found + */ TimebaseEvent* GetEvent(uint16_t bar, uint16_t clock); /** @brief Get quantity of timebase events - * @retval uint32_t Quanity of timebase events in map - */ + @retval uint32_t Quanity of timebase events in map + */ uint32_t getEventQuant(); /** @brief Get timebase event by index - * @param index Index of event - * @retval TimebaseEvent* Pointer to event. Null if invalid index - */ + @param index Index of event + @retval TimebaseEvent* Pointer to event. Null if invalid index + */ TimebaseEvent* getEvent(size_t index); private: diff --git a/zynlibs/zynseq/track.cpp b/zynlibs/zynseq/track.cpp index 08f718341..cbc25a3b0 100644 --- a/zynlibs/zynseq/track.cpp +++ b/zynlibs/zynseq/track.cpp @@ -15,12 +15,12 @@ std::normal_distribution d{0.0, 1.0}; bool Track::addPattern(uint32_t position, Pattern* pattern, bool force) { // Find (and remove) overlapping patterns uint32_t nStart = position; - uint32_t nEnd = nStart + pattern->getLength(); + uint32_t nEnd = nStart + pattern->getLength(); for (uint32_t nClock = 0; nClock <= position + pattern->getLength(); ++nClock) { if (m_mPatterns.find(nClock) != m_mPatterns.end()) { - Pattern* pPattern = m_mPatterns[nClock]; + Pattern* pPattern = m_mPatterns[nClock]; uint32_t nExistingStart = nClock; - uint32_t nExistingEnd = nExistingStart + pPattern->getLength(); + uint32_t nExistingEnd = nExistingStart + pPattern->getLength(); if ((nStart >= nExistingStart && nStart < nExistingEnd) || (nEnd > nExistingStart && nEnd <= nExistingEnd)) { if (!force) @@ -34,7 +34,7 @@ bool Track::addPattern(uint32_t position, Pattern* pattern, bool force) { } m_mPatterns[position] = pattern; if (m_nTrackLength < position + pattern->getLength()) - m_nTrackLength = position + pattern->getLength(); //!@todo Does this shrink and stretch song? + m_nTrackLength = position + pattern->getLength(); //!@todo Does this shrink and stretch scene? m_bChanged = true; return true; } @@ -62,20 +62,6 @@ Pattern* Track::getPatternAt(uint32_t position) { return NULL; } -uint8_t Track::getType() { return m_nType; } - -void Track::setType(uint8_t type) { - m_nType = type; - m_bChanged = true; -} - -uint8_t Track::getChainID() { return m_nChainID; } - -void Track::setChainID(uint8_t chain_id) { - m_nChainID = chain_id; - m_bChanged = true; -} - uint8_t Track::getChannel() { return m_nChannel; } void Track::setChannel(uint8_t channel) { @@ -88,39 +74,38 @@ void Track::setChannel(uint8_t channel) { uint8_t Track::getOutput() { return m_nOutput; } void Track::setOutput(uint8_t output) { - m_nOutput = output; + m_nOutput = output; m_bChanged = true; } -uint8_t Track::clock(uint32_t nTime, uint32_t nPosition, uint32_t nSamplesPerClock, bool bSync) { +uint8_t Track::clock(uint32_t nTime, uint32_t nPosition, bool bSync) { if (m_nTrackLength == 0) return 0; if (m_bMute) return 0; - m_nSamplesPerClock = nSamplesPerClock; if (m_mPatterns.find(nPosition) != m_mPatterns.end()) { // Playhead at start of pattern // fprintf(stderr, "Start of pattern\n"); m_nCurrentPatternPos = nPosition; - m_nNextStep = 0; - m_nNextEvent = 0; - m_nClkPerStep = m_mPatterns[m_nCurrentPatternPos]->getClocksPerStep(); + m_nNextStep = 0; + m_nNextEvent = 0; + m_nClkPerStep = m_mPatterns[m_nCurrentPatternPos]->getClocksPerStep(); if (m_nClkPerStep == 0) m_nClkPerStep = 1; - m_nEventValue = -1; - m_nDivCount = 0; // Trigger first step immediately + m_nEventValue = -1; + m_nDivCount = 0; // Trigger first step immediately m_nLastClockTime = nTime; // fprintf(stderr, "m_nCurrentPatternPos: %u m_nClkPerStep: %u\n", m_nCurrentPatternPos, m_nClkPerStep); } else if (m_nCurrentPatternPos >= 0 && nPosition >= m_nCurrentPatternPos + m_mPatterns[m_nCurrentPatternPos]->getLength()) { // At end of pattern // fprintf(stderr, "End of pattern\n"); m_nCurrentPatternPos = -1; - m_nNextEvent = -1; - m_nNextStep = 0; - m_nClkPerStep = 1; - m_nEventValue = -1; - m_nDivCount = 0; + m_nNextEvent = -1; + m_nNextStep = 0; + m_nClkPerStep = 1; + m_nEventValue = -1; + m_nDivCount = 0; } else { // Within pattern ++m_nDivCount; @@ -131,7 +116,7 @@ uint8_t Track::clock(uint32_t nTime, uint32_t nPosition, uint32_t nSamplesPerClo // Reached next step // fprintf(stderr, "Reached next step \n"); m_nLastClockTime = nTime; - m_nDivCount = 0; + m_nDivCount = 0; ++m_nNextStep; m_nNextEvent = m_mPatterns[m_nCurrentPatternPos]->getFirstEventAtStep(m_nNextStep); //!@todo Could disable this check only when not editing pattern } @@ -139,97 +124,247 @@ uint8_t Track::clock(uint32_t nTime, uint32_t nPosition, uint32_t nSamplesPerClo return m_nCurrentPatternPos >= 0 && m_nDivCount == 0; } -SEQ_EVENT* Track::getEvent() { +bool get_skip_from_freq(uint8_t freq, uint32_t nCount) { + uint8_t n = 1 + (freq >> 1); + uint8_t skip = freq & 0x1; + if (n == 1) { + if (skip) return false; // Skip never (note enabled) + else return true; // Skip always (note disabled) + } + else { + // if skip == 1 => skip each n counts + if (skip) { + if (nCount % n == 0) return true; + else return false; + } + // if skip == 0 => play each n counts + else { + if (nCount % n != 0) return true; + else return false; + } + } +} + +SEQ_EVENT* Track::getEvent(uint32_t nCount) { // This function is called repeatedly for each clock period until no more events are available to populate JACK MIDI output schedule - static SEQ_EVENT seqEvent; // A MIDI event timestamped for some imminent or future time - static uint32_t nStutterCount = 0; // Count stutters already added to this event + static SEQ_EVENT seqEvent; // A MIDI event timestamped for some imminent or future time + static uint32_t nEventEndTime; // End of event time (optimization) + static uint32_t nStutterSpeed = 0; // Current stutter speed for this event + static uint32_t nStutterCount = 0; // Count stutters already added to this event + static uint32_t nInterpolateCount = 0; // Count interpolations already added to this event + static uint32_t nInterpolateNum = 0; // Number of interpolation points for this event + static float fInterpolateDelta = 0; // Value delta for each interpolation point if (m_nCurrentPatternPos < 0 || m_nNextEvent < 0) return NULL; //!@todo Can we stop between note on and note off being processed resulting in stuck note? // Track is being played and playhead is within a pattern Pattern* pPattern = m_mPatterns[m_nCurrentPatternPos]; StepEvent* pEvent = pPattern->getEventAt(m_nNextEvent); // Don't advance event here because need to interpolate - // fprintf(stderr, "Track::getEvent Next step:%u, next event:%u, event %u at time: %u, framesperclock: %f\n", m_nNextStep, m_nNextEvent, pEvent, - // pEvent->getPosition(), m_nSamplesPerClock); + + // Found event at (or before) this step if (pEvent && pEvent->getPosition() == m_nNextStep) { - // Found event at (or before) this step + + // We have reached the end of interpolation so move on to next event if (m_nEventValue == pEvent->getValue2end()) { - // We have reached the end of interpolation so move on to next event m_nEventValue = -1; pEvent = pPattern->getEventAt(++m_nNextEvent); + // No more events or next event is not this step so move to next step if (!pEvent || pEvent->getPosition() != m_nNextStep) { - // No more events or next event is not this step so move to next step return NULL; } } + uint8_t nCommand = pEvent->getCommand(); - seqEvent.msg.command = nCommand | m_nChannel; + + seqEvent.output = m_nOutput; //fprintf(stderr, " found event at %u => %x, %u, %u\n", m_nNextStep, nCommand, pEvent->getValue1start(), pEvent->getValue2start()); - // Have not yet started to interpolate value + // Last event already finished => Start new event! if (m_nEventValue == -1) { - // Note Play Chance - unsigned playChance = unsigned(RAND_MAX * pPattern->getPlayChance() * pEvent->getPlayChance() / 100.0); - if (playChance < RAND_MAX && playChance < rand()) { - m_nEventValue = pEvent->getValue2end(); - seqEvent.msg.command = 0xFE; - return &seqEvent; + // Play Frequency & Chance + if (nCommand == MIDI_NOTE_ON) { + // Play frequency + bool skip = get_skip_from_freq(pEvent->getPlayFreq(), nCount); + // Play chance (probability) => concatenated with frequency calc + if (!skip) { + if (unsigned(RAND_MAX * pPattern->getPlayChance() * pEvent->getPlayChance()) < rand()) + skip = true; + } + if (skip) { + m_nEventValue = pEvent->getValue2end(); + seqEvent.msg.command = 0xFE; + return &seqEvent; + } } - // Start interpolation + seqEvent.msg.command = nCommand | m_nChannel; + // Start value (interpolation) m_nEventValue = pEvent->getValue2start(); - // Recorded Offset (fraction of step => float) + // Offset (fraction of step => float) m_fEventOffset = pEvent->getOffset(); - // Real-time quantization (step quantization => TODO quantize to step divisors: 1/2, 1/3, 1/4, 1/6, 1/8, ...) - if (pPattern->getQuantizeNotes()) { - if (m_fEventOffset > 0.5) m_fEventOffset = 1.0; - else m_fEventOffset = 0.0; + // Setup interpolation + nInterpolateCount = 0; + nInterpolateNum = pEvent->getDuration() * pPattern->getClocksPerStep(); + fInterpolateDelta = ((float)pEvent->getValue2end() - (float)pEvent->getValue2start()) / nInterpolateNum; + // Note quantization, swing & time humanization + if (nCommand == MIDI_NOTE_ON) { + // Real-time quantization (step quantization + uint8_t qn = pPattern->getQuantizeNotes(); + // Quantize to step boundary (qn = 1) + if (qn == 1) { + if (m_fEventOffset > 0.5) m_fEventOffset = 1.0; + else m_fEventOffset = 0.0; + } + // Quantize to step fraction boundary (1/qn => 1/2, 1/3, 1/4, 1/6, 1/8, ...) + else if (qn > 1) { + float os = m_fEventOffset; + float m = m_fEventOffset * qn; + uint8_t mi = (int)m; + if ((m - mi) > 0.5) m_fEventOffset = (float)(mi + 1) / (float)qn; + else m_fEventOffset = (float)mi / (float)qn; + //printf("Event %d with Offset %f => Quantized (1/%d) to %f\n", pEvent->getPosition(), os, qn, m_fEventOffset); + } + // Swing => Add to offset + uint32_t swingDiv = pPattern->getSwingDiv(); + float swingAmount = pPattern->getSwingAmount(); + uint32_t swingStep = m_nNextStep + swingDiv; + if (m_fEventOffset > 0.5) swingStep++; + if (swingStep % (2 * swingDiv) == 0) { + m_fEventOffset += swingAmount * swingDiv / 2; + } + // Time humanization => Add to offset + float humanTime = pPattern->getHumanTime(); + if (humanTime > 0.0) + m_fEventOffset += humanTime * d(gen); + // Setup stutter + nStutterCount = 0; + nStutterSpeed = pEvent->getStutterSpeed(); + if (nStutterSpeed > 0) { + // Stutter frequency + bool skip = get_skip_from_freq(pEvent->getStutterFreq(), nCount); + // Stutter chance (probability) => concatenated with frequency calc + if (!skip) { + if (unsigned(RAND_MAX * pEvent->getStutterChance()) < rand()) + skip = true; + } + if (skip) + nStutterSpeed = 0; + else + // If stutter FX is fade-in => start interpolation from lowest velocity value + if (pEvent->getStutterVelfx() == STUTTER_VELFX_FADEIN) { + nInterpolateCount = pPattern->getClocksPerStep() / nStutterSpeed; + m_nEventValue = 1 - fInterpolateDelta * nInterpolateCount; + } + } } - // Swing => Add to offset - uint32_t swingDiv = pPattern->getSwingDiv(); - float swingAmount = pPattern->getSwingAmount(); - uint32_t swingStep = m_nNextStep + swingDiv; - if (m_fEventOffset > 0.5) swingStep++; - if (swingStep % (2 * swingDiv) == 0) { - m_fEventOffset += swingAmount; + else { + //nStutterCount = 0; + nStutterSpeed = 0; } - // Time humanization => Add to offset - float humanTime = pPattern->getHumanTime(); - if (humanTime > 0.0) - m_fEventOffset += humanTime * d(gen); // Calculate event scheduled time - seqEvent.time = m_nLastClockTime + m_fEventOffset * pPattern->getClocksPerStep() * m_nSamplesPerClock; - // Reset Stutter - nStutterCount = 0; - } else if (pEvent->getValue2start() == m_nEventValue) { - //!@todo Don't get here if start and end values are the same, e.g. note on and off velocity are both 100 - // Already processed start value - // Add note off/on for each stutter - if (nCommand == MIDI_NOTE_ON) - seqEvent.msg.command = (nStutterCount % 2 ? MIDI_NOTE_ON : MIDI_NOTE_OFF) | m_nChannel; - seqEvent.time = m_nLastClockTime + (m_fEventOffset + pEvent->getDuration()) * pPattern->getClocksPerStep() * m_nSamplesPerClock - - 1; // -1 to send note-off one sample before next step - if (pEvent->getStutterCount()) { - uint32_t stutter_time = m_nLastClockTime + (m_fEventOffset + pEvent->getStutterDur()) * ++nStutterCount * m_nSamplesPerClock; - if (stutter_time < seqEvent.time && 2 * pEvent->getStutterCount() >= nStutterCount) - seqEvent.time = stutter_time; - else - m_nEventValue = pEvent->getValue2end(); - } else - m_nEventValue = pEvent->getValue2end(); //!@todo Currently just move straight to end value but should interpolate for CC - //fprintf(stderr, "Scheduling note off. Event duration: %u, clocks per step: %u, samples per clock: %u\n", pEvent->getDuration(), - // pPattern->getClocksPerStep(), m_nSamplesPerClock); + seqEvent.time = m_nLastClockTime + m_fEventOffset * pPattern->getClocksPerStep(); + // Calculate note-off event scheduled time + // -1 to send note-off one tick before next step + nEventEndTime = seqEvent.time + pEvent->getDuration() * pPattern->getClocksPerStep() - 1; + } + // Event already started + else if (m_nEventValue != pEvent->getValue2end()) { + // Process note stutter => Normal note off is processed like stutter. By default a note has stutter=0. + if (nCommand == MIDI_NOTE_ON) { + // Stutter enabled => Add note off/on for each stutter + if (nStutterSpeed > 0) { + uint32_t clocks_per_step= pPattern->getClocksPerStep(); + // Stutter speed-ramp FX + uint8_t speed = nStutterSpeed; + switch (pEvent->getStutterRamp()) { + case STUTTER_RAMP_NONE: + break; + case STUTTER_RAMP_UP: + speed += (seqEvent.time - m_nLastClockTime) / clocks_per_step; + break; + case STUTTER_RAMP_DOWN: + speed += pEvent->getDuration() - (seqEvent.time - m_nLastClockTime) / clocks_per_step; + break; + } + // Calculate stutter event time + uint32_t nclocks; + if (speed > clocks_per_step) uint32_t nclocks = 1; + else if (speed <= 0) nclocks = clocks_per_step; + else nclocks = clocks_per_step / speed; + uint32_t stutter_time = seqEvent.time + nclocks; + // If not reached end of note + if (stutter_time < nEventEndTime) { + seqEvent.time = stutter_time; + // Stutter alternate note-off and note-on events + seqEvent.msg.command = (nStutterCount % 2 ? MIDI_NOTE_ON : MIDI_NOTE_OFF) | m_nChannel; + nStutterCount++; + // Stutter velocity FX => Interpolate velocity + nInterpolateCount += nclocks; + switch (pEvent->getStutterVelfx()) { + // Flat + case STUTTER_VELFX_NONE: + break; + // Fade-in + case STUTTER_VELFX_FADEIN: + m_nEventValue = - fInterpolateDelta * nInterpolateCount; + break; + // Fade-out + case STUTTER_VELFX_FADEOUT: + m_nEventValue = pEvent->getValue2start() + fInterpolateDelta * nInterpolateCount; + break; + } + } + // End of note + else { + seqEvent.time = nEventEndTime; + seqEvent.msg.command = MIDI_NOTE_OFF | m_nChannel; + m_nEventValue = pEvent->getValue2end(); // send end value + } + // Stutter disabled (stutter speed = 0) + } else { + seqEvent.time = nEventEndTime; + seqEvent.msg.command = MIDI_NOTE_OFF | m_nChannel; + m_nEventValue = pEvent->getValue2end(); // send end value + } + } + // CC interpolation => one value per clock but don't repeat values + else if (nCommand == MIDI_CONTROL) { + int8_t start_value = pEvent->getValue2start(); + int8_t next_value; + do { + seqEvent.time++; + nInterpolateCount++; + next_value = start_value + fInterpolateDelta * nInterpolateCount; + } while (seqEvent.time < nEventEndTime && next_value == m_nEventValue); + // If reached event's end time, send end value + if (seqEvent.time >= nEventEndTime) { + seqEvent.time = nEventEndTime; + next_value = pEvent->getValue2end(); // send end value + } + // Don't send repeated values + if (next_value == m_nEventValue) { + seqEvent.msg.command = 0xFE; + return &seqEvent; + } + seqEvent.msg.command = nCommand | m_nChannel; + m_nEventValue = next_value; + //fprintf(stderr, "Scheduling CC%u interpolated value => %u, %u\n", pEvent->getValue1start(), seqEvent.time, m_nEventValue); + } } + + // Set MIDI event values seqEvent.msg.value1 = pEvent->getValue1start(); - // Velocity humanization - int8_t hval2 = m_nEventValue; - float humanVelo = pPattern->getHumanVelo(); - if (humanVelo > 0.0) { - int16_t dvelo = int16_t(humanVelo * d(gen)); - hval2 = int8_t(std::min(std::max(int16_t(hval2) + dvelo, 0), 127)); + if (nCommand == MIDI_NOTE_ON) { + // Velocity humanization + float humanVelo = pPattern->getHumanVelo(); + if (humanVelo > 0.0) { + int16_t dvelo = int16_t(humanVelo * d(gen)); + seqEvent.msg.value2 = int8_t(std::min(std::max(int16_t(m_nEventValue) + dvelo, 0), 127)); + } else { + seqEvent.msg.value2 = m_nEventValue; + } + } else { + seqEvent.msg.value2 = m_nEventValue; } - seqEvent.msg.value2 = hval2; - //fprintf(stderr, "Track::getEvent Scheduled event %x,%u,%u at %u currentTime: %u duration: %u clkperstep: %u sampleperclock: %f event position: %u\n", - // seqEvent.msg.command, seqEvent.msg.value1, seqEvent.msg.value2, seqEvent.time, m_nLastClockTime, pEvent->getDuration(), pPattern->getClocksPerStep(), - // m_nSamplesPerClock, pEvent->getPosition()); + return &seqEvent; } m_nEventValue = -1; @@ -241,7 +376,7 @@ SEQ_EVENT* Track::getEvent() { uint32_t Track::updateLength() { m_nTrackLength = 0; - m_bEmpty = true; + m_bEmpty = true; for (auto it = m_mPatterns.begin(); it != m_mPatterns.end(); ++it) { if (it->first + it->second->getLength() > m_nTrackLength) m_nTrackLength = it->first + it->second->getLength(); @@ -255,19 +390,19 @@ uint32_t Track::getLength() { return m_nTrackLength; } void Track::clear() { m_mPatterns.clear(); - m_nTrackLength = 0; - m_nEventValue = -1; + m_nTrackLength = 0; + m_nEventValue = -1; m_nCurrentPatternPos = -1; - m_nNextEvent = -1; - m_nNextStep = 0; - m_nClkPerStep = 1; - m_nDivCount = 0; - m_bChanged = true; + m_nNextEvent = -1; + m_nNextStep = 0; + m_nClkPerStep = 1; + m_nDivCount = 0; + m_bChanged = true; } void Track::setPosition(uint32_t position) { - m_nDivCount = 0; - m_nNextStep = position / m_nClkPerStep; + m_nDivCount = 0; + m_nNextStep = position / m_nClkPerStep; // fprintf(stderr, "setPosition: next step: %d\n", m_nNextStep); m_nNextEvent = -1; // Avoid playing wrong pattern for (auto it = m_mPatterns.begin(); it != m_mPatterns.end(); ++it) { @@ -301,10 +436,10 @@ void Track::solo(bool solo) { m_bSolo = solo; } bool Track::isSolo() { return m_bSolo; } void Track::mute(bool mute) { - m_bMute = mute; - m_nEventValue = -1; + m_bMute = mute; + m_nEventValue = -1; m_nCurrentPatternPos = -1; - m_nNextEvent = -1; + m_nNextEvent = -1; } bool Track::isMuted() { return m_bMute; } diff --git a/zynlibs/zynseq/track.h b/zynlibs/zynseq/track.h index 6c5fd933d..15b817b29 100644 --- a/zynlibs/zynseq/track.h +++ b/zynlibs/zynseq/track.h @@ -5,211 +5,189 @@ struct SEQ_EVENT { uint32_t time; + uint8_t output; MIDI_MESSAGE msg; }; /** Track class provides an arbritary quantity of non-overlapping patterns. * One or more tracks are grouped into a sequence and played in unison. * Each track may be muted / soloed and drive a different MIDI channel - */ +*/ class Track { public: - /** @brief Add pattern to track - * @param position Quantity of clock cycles from start of track at which to add pattern - * @param pattern Pointer to pattern to add - * @param force True to remove overlapping patterns, false to fail if overlapping patterns (Default: false) - * @retval bool True if pattern added - * @todo Handle pattern pointer becoming invalid - */ + /** @brief Add pattern to track + @param position Quantity of clock cycles from start of track at which to add pattern + @param pattern Pointer to pattern to add + @param force True to remove overlapping patterns, false to fail if overlapping patterns (Default: false) + @retval bool True if pattern added + @todo Handle pattern pointer becoming invalid + */ bool addPattern(uint32_t position, Pattern* pattern, bool force = false); /** @brief Remove pattern from track - * @param position Quantity of clock cycles from start of track at which pattern starts - */ + @param position Quantity of clock cycles from start of track at which pattern starts + */ void removePattern(uint32_t position); /** @brief Get pattern starting at position - * @param position Quantity of clock cycles from start of track at which pattern starts - * @retval Pattern* Pointer to pattern or NULL if no pattern starts at this position - */ + @param position Quantity of clock cycles from start of track at which pattern starts + @retval Pattern* Pointer to pattern or NULL if no pattern starts at this position + */ Pattern* getPattern(uint32_t position); /** @brief Get pattern spanning position - * @param position Quantity of clock cycles from start of track at which pattern spans - * @retval Pattern* Pointer to pattern or NULL if no pattern starts at this position - */ + @param position Quantity of clock cycles from start of track at which pattern spans + @retval Pattern* Pointer to pattern or NULL if no pattern starts at this position + */ Pattern* getPatternAt(uint32_t position); - /** @brief Get type - * @retval uint8_t type - */ - uint8_t getType(); - - /** @brief Set type - * @param type Track type - */ - void setType(uint8_t type); - - /** @brief Get Chain ID - * @retval uint8_t Chain ID - */ - uint8_t getChainID(); - - /** @brief Set Chain ID - * @param chain_id Chain ID - */ - void setChainID(uint8_t chain_id); - /** @brief Get MIDI channel - * @retval uint8_t MIDI channel - */ + @retval uint8_t MIDI channel + */ uint8_t getChannel(); /** @brief Set MIDI channel - * @param channel MIDI channel - */ + @param channel MIDI channel + */ void setChannel(uint8_t channel); /** @brief Get JACK output - * @retval uint8_t JACK output - */ + @retval uint8_t JACK output + */ uint8_t getOutput(); /** @brief Set JACK output - * @param channel JACK output - */ + @param channel JACK output + */ void setOutput(uint8_t output); /** @brief Handle clock signal - * @param nTime Time (quantity of samples since JACK epoch) - * @param nPosition Play position within sequence in clock cycles - * @param nSamplesPerClock Samples per clock - * @param bSync True if sync point - * @retval uint8_t 1 if a step needs processing for this track - * @note Tracks are clocked syncronously but not locked to absolute time so depend on start time for absolute timing - */ - uint8_t clock(uint32_t nTime, uint32_t nPosition, uint32_t nSamplesPerClock, bool bSync); + @param nTime Time (quantity of ticks since tick epoch) + @param nPosition Play position within sequence in clock cycles + @param bSync True if sync point + @retval uint8_t 1 if a step needs processing for this track + @note Tracks are clocked syncronously but not locked to absolute time so depend on start time for absolute timing + */ + uint8_t clock(uint32_t nTime, uint32_t nPosition, bool bSync); /** @brief Gets next event at current clock cycle - * @retval SEQ_EVENT* Pointer to sequence event at this time or NULL if no more events - * @note Start, end and interpolated events are returned on each call. Time is offset from start of clock cycle in samples. - */ - SEQ_EVENT* getEvent(); + @param unit32_t Number of times the track has been played + @retval SEQ_EVENT* Pointer to sequence event at this time or NULL if no more events + @note Start, end and interpolated events are returned on each call. Time is offset from start of clock cycle in ticks. + */ + SEQ_EVENT* getEvent(uint32_t nCount); /** @brief Update length of track by iterating through all patterns to find last clock cycle - * @retval uint32_t Duration of track in clock cycles - */ + @retval uint32_t Duration of track in clock cycles + */ uint32_t updateLength(); /** @brief Get duration of track in clock cycles - * @retval uint32_t Length of track in clock cycles - */ + @retval uint32_t Length of track in clock cycles + */ uint32_t getLength(); /** @brief Remove all patterns from track - */ + */ void clear(); /** @brief Set position - * @param position Quantity of clocks since start of track - */ + @param position Quantity of clocks since start of track + */ void setPosition(uint32_t position); /** @brief Get position of next pattern in track - * @param previous Position of previous pattern (Empty to get first pattern) - * @retval uint32_t Position of next pattern or 0xFFFFFFFF if no more patterns - */ + @param previous Position of previous pattern (Empty to get first pattern) + @retval uint32_t Position of next pattern or 0xFFFFFFFF if no more patterns + */ uint32_t getNextPattern(uint32_t previous = 0xFFFFFFFF); /** @brief Get quantity of patterns in track - * @retval uint32_t Quantity of patterns in track - */ + @retval uint32_t Quantity of patterns in track + */ size_t getPatterns(); /** @brief Set map / scale index - * @param map - */ + @param map + */ void setMap(uint8_t map); /** @brief Get map / scale index - * @retval uint8_t Map / scale index - */ + @retval uint8_t Map / scale index + */ uint8_t getMap(); /** @brief Solo track - * @param solo True to solo [Default: true] - */ + @param solo True to solo [Default: true] + */ void solo(bool solo = true); /** @brief Get solo state of track - * @retval bool True if solo - */ + @retval bool True if solo + */ bool isSolo(); /** @brief Mute track - * @param mute True to solo [Default: true] - */ + @param mute True to solo [Default: true] + */ void mute(bool mute = true); /** @brief Get mute state of track - * @retval bool True if muted - */ + @retval bool True if muted + */ bool isMuted(); - /** @brief Flag track as modified - */ + /** @brief Flag track as modified + */ void setModified(); /** @brief Check if a parameter has changed since last call - * @retval bool True if changed - * @note monitors: state, mode, group - */ + @retval bool True if changed + @note monitors: state, mode, group + */ bool isModified(); /** @brief Gets the pattern defined by index - * @param index Index of pattern - * @retval Pattern* Pointer to pattern or Null if no pattern at index. - * @note Adding, removing or moving patterns may invalidate the index - */ + @param index Index of pattern + @retval Pattern* Pointer to pattern or Null if no pattern at index. + @note Adding, removing or moving patterns may invalidate the index + */ Pattern* getPatternByIndex(size_t index); /** @brief Get position of pattern defined by index - * @param index Index of pattern - * @retval uint32_t Position in clock cycles or -1 if invalid index - */ + @param index Index of pattern + @retval uint32_t Position in clock cycles or -1 if invalid index + */ uint32_t getPatternPositionByIndex(size_t index); /** @brief Get position of pattern defined by pattern pointer - * @param pattern Pointer to pattern - * @retval uint32_t Position in clock cycles or -1 if invalid index - */ + @param pattern Pointer to pattern + @retval uint32_t Position in clock cycles or -1 if invalid index + */ uint32_t getPatternPosition(Pattern* pattern); /** @brief Check if track is empty (contains no events in any patterns) - * @retval bool True if emtpy - * @note Status updated after call to getLength(); - */ + @retval bool True if emtpy + @note Status updated after call to getLength(); + */ bool isEmpty(); private: - uint8_t m_nType = 0; // 0 = MIDI Track, 1 = Audio, 2 = MIDI Program - uint8_t m_nChainID = 0; // Associated Chain ID. 0 for none. - uint8_t m_nChannel = 0; // MIDI channel - uint8_t m_nOutput = 0; // JACK output - uint8_t m_nMap = 0; // Map / scale index + uint8_t m_nChannel = 0; // MIDI channel + uint8_t m_nOutput = 0; // JACK output + uint8_t m_nMap = 0; // Map / scale index uint32_t m_nClkPerStep = 1; // Clock cycles per step - uint32_t m_nDivCount = 0; // Current count of clock cycles within divisor + uint32_t m_nDivCount = 0; // Current count of clock cycles within divisor std::map m_mPatterns; // Map of pointers to patterns, indexed by start position - int m_nCurrentPatternPos = -1; // Start position of pattern currently being played - int m_nNextEvent = -1; // Index of next event to process or -1 if no more events at this clock cycle - int8_t m_nEventValue = -1; // Value of event at current interpolation point or -1 if no event - float m_fEventOffset = 0; // Offset for the currently processed Step event (getEvent) - uint32_t m_nLastClockTime = 0; // Time of last clock pulse (sample) - uint32_t m_nNextStep = 0; // Postion within pattern (step) - uint32_t m_nTrackLength = 0; // Quantity of clock cycles in track (last pattern start + length) - uint32_t m_nSamplesPerClock; // Quantity of samples per MIDI clock cycle used to schedule future events, e.g. note off / interpolation - bool m_bSolo = false; // True if track is solo - bool m_bMute = false; // True if track is muted + int m_nCurrentPatternPos = -1; // Start position of pattern currently being played + int m_nNextEvent = -1; // Index of next event to process or -1 if no more events at this clock cycle + int8_t m_nEventValue = -1; // Value of event at current interpolation point or -1 if no event + float m_fEventOffset = 0; // Offset for the currently processed Step event (getEvent) + uint32_t m_nLastClockTime = 0; // Time of last clock pulse (ticks) + uint32_t m_nNextStep = 0; // Postion within pattern (step) + uint32_t m_nTrackLength = 0; // Quantity of clock cycles in track (last pattern start + length) + bool m_bSolo = false; // True if track is solo + bool m_bMute = false; // True if track is muted bool m_bChanged = true; // True if state changed since last hasChanged() - bool m_bEmpty = true; // True if all patterns in track are empty (have no events) + bool m_bEmpty = true; // True if all patterns in track are empty (have no events) }; diff --git a/zynlibs/zynseq/unit_tests.py b/zynlibs/zynseq/unit_tests.py index 48667f392..f813ae1c2 100755 --- a/zynlibs/zynseq/unit_tests.py +++ b/zynlibs/zynseq/unit_tests.py @@ -80,7 +80,7 @@ def check_pattern(self, beat_type, steps_per_beat, beats_in_pattern): self.assertEqual(libseq.getSteps(), steps_in_pattern) self.assertEqual(libseq.getBeatType(), beat_type) self.assertEqual(libseq.getStepsPerBeat(), steps_per_beat) - self.assertEqual(libseq.getBeatsInPattern(), beats_in_pattern) + self.assertEqual(libseq.getBeatsInPattern(0), beats_in_pattern) self.assertEqual(libseq.getClocksPerStep(), clocks_per_step) self.assertEqual(libseq.getPatternLength( libseq.getPatternIndex()), clocks_per_step * steps_in_pattern) @@ -223,7 +223,7 @@ def test_ae01_remove_pattern(self): # Impact on other elements of changing patterns def test_af00_change_pattern_length(self): sequence = libseq.getSequence(1,1) - libseq.clearSong(1) + libseq.clearScene(1) self.assertEqual(libseq.getSequenceLength(sequence), 0) libseq.selectPattern(1) libseq.clear() @@ -286,7 +286,7 @@ def test_ag01_sync_play(self): libseq.setPlayMode(sequence2,play_mode["LOOPSYNC"]) libseq.setPlayState(sequence1, play_state["STARTING"]) sleep(0.1) - self.assertEqual(libseq.transportGetPlayStatus(), jack.ROLLING) + self.assertEqual(libseq.jackTransportGetPlayStatus(), jack.ROLLING) self.assertEqual(libseq.getPlayState(sequence1), play_state["PLAYING"]) libseq.setPlayState(sequence2, play_state["STARTING"]) sleep(1.8) @@ -322,7 +322,7 @@ def test_ag02_playback_modes(self): self.assertEqual(libseq.getPlayMode(sequence), mode) libseq.setPlayState(sequence, play_state["STARTING"]) sleep(0.1) - self.assertEqual(libseq.transportGetPlayStatus(), jack.ROLLING) + self.assertEqual(libseq.jackTransportGetPlayStatus(), jack.ROLLING) self.assertEqual(libseq.getPlayState(sequence), play_state["PLAYING"]) sleep(2.2) self.assertEqual(libseq.getPlayState(sequence), play_state["PLAYING"]) @@ -355,7 +355,7 @@ def test_ag02_playback_modes(self): self.assertEqual(libseq.getPlayMode(sequence), mode) libseq.setPlayState(sequence, play_state["STARTING"]) sleep(0.1) - self.assertEqual(libseq.transportGetPlayStatus(), jack.ROLLING) + self.assertEqual(libseq.jackTransportGetPlayStatus(), jack.ROLLING) self.assertEqual(libseq.getPlayState(sequence), play_state["PLAYING"]) sleep(1.8) tb=client.transport_query() @@ -376,7 +376,7 @@ def test_ag02_playback_modes(self): self.assertEqual(libseq.getPlayMode(sequence), mode) libseq.setPlayState(sequence, play_state["STARTING"]) sleep(0.1) - self.assertEqual(libseq.transportGetPlayStatus(), jack.ROLLING) + self.assertEqual(libseq.jackTransportGetPlayStatus(), jack.ROLLING) self.assertEqual(libseq.getPlayState(sequence), play_state["PLAYING"]) for beat in range (0,5): self.assertEqual(client.transport_query()[1]['beat'], (beat%4)+1) @@ -389,7 +389,7 @@ def test_ag02_playback_modes(self): # one-shot-all, stopping before end libseq.setPlayState(sequence, play_state["STARTING"]) sleep(0.1) - self.assertEqual(libseq.transportGetPlayStatus(), jack.ROLLING) + self.assertEqual(libseq.jackTransportGetPlayStatus(), jack.ROLLING) self.assertEqual(libseq.getPlayState(sequence), play_state["PLAYING"]) libseq.setPlayState(sequence, play_state["STOPPING"]) for beat in range (0,5): @@ -406,7 +406,7 @@ def test_ag02_playback_modes(self): self.assertEqual(libseq.getPlayMode(sequence), mode) libseq.setPlayState(sequence, play_state["STARTING"]) sleep(0.1) - self.assertEqual(libseq.transportGetPlayStatus(), jack.ROLLING) + self.assertEqual(libseq.jackTransportGetPlayStatus(), jack.ROLLING) self.assertEqual(libseq.getPlayState(sequence), play_state["PLAYING"]) for beat in range (0,5): self.assertEqual(client.transport_query()[1]['beat'], (beat%4)+1) @@ -427,7 +427,7 @@ def test_ag02_playback_modes(self): self.assertEqual(libseq.getPlayMode(sequence), mode) libseq.setPlayState(sequence, play_state["STARTING"]) sleep(0.1) - self.assertEqual(libseq.transportGetPlayStatus(), jack.ROLLING) + self.assertEqual(libseq.jackTransportGetPlayStatus(), jack.ROLLING) self.assertEqual(libseq.getPlayState(sequence), play_state["PLAYING"]) for beat in range (0,5): self.assertEqual(client.transport_query()[1]['beat'], (beat%4)+1) @@ -440,7 +440,7 @@ def test_ag02_playback_modes(self): # One shot sync - stop before end libseq.setPlayState(sequence, play_state["STARTING"]) sleep(0.1) - self.assertEqual(libseq.transportGetPlayStatus(), jack.ROLLING) + self.assertEqual(libseq.jackTransportGetPlayStatus(), jack.ROLLING) self.assertEqual(libseq.getPlayState(sequence), play_state["PLAYING"]) libseq.setPlayState(sequence, play_state["STOPPING"]) for beat in range (0,4): @@ -456,7 +456,7 @@ def test_ag02_playback_modes(self): self.assertEqual(libseq.getPlayMode(sequence), mode) libseq.setPlayState(sequence, play_state["STARTING"]) sleep(0.1) - self.assertEqual(libseq.transportGetPlayStatus(), jack.ROLLING) + self.assertEqual(libseq.jackTransportGetPlayStatus(), jack.ROLLING) self.assertEqual(libseq.getPlayState(sequence), play_state["PLAYING"]) for beat in range (0,5): self.assertEqual(client.transport_query()[1]['beat'], (beat%4)+1) @@ -523,7 +523,7 @@ def test_ah02_playback(self): # MIDI playback channel def test_ah03_playback_channel(self): self.assertEqual(client.transport_state, jack.STOPPED) - libseq.selectSong(2) + libseq.selectScene(2) sequence = libseq.getSequence(1002,libseq.addTrack(1002)) libseq.addPattern(sequence,0,100,True) libseq.setPlayMode(sequence, play_mode["ONESHOT"]) @@ -542,7 +542,7 @@ def test_ah03_playback_channel(self): # MIDI trigger inputs def test_ah04_trigger(self): global send_midi - libseq.selectSong(1) + libseq.selectScene(1) sequence = libseq.getSequence(1001,libseq.addTrack(1001)) libseq.clearSequence(sequence) #TODO: Do we need to clear sequence - should check it is clear by default libseq.selectPattern(999) @@ -584,7 +584,7 @@ def test_ah04_trigger(self): def test_ah06_groups(self): client.transport_stop() sleep(0.3) - libseq.selectSong(2) + libseq.selectScene(2) libseq.selectPattern(1) libseq.clear() libseq.setBeatsInPattern(4) @@ -630,12 +630,12 @@ def test_ah06_groups(self): self.assertEqual(libseq.getPlayState(sequenceA1), play_state["STOPPED"]) self.assertEqual(libseq.getPlayState(sequenceA2), play_state["STOPPED"]) self.assertEqual(libseq.getPlayState(sequenceB1), play_state["STOPPED"]) - # Song management - def test_ai00_song(self): + # Scene management + def test_ai00_Scene(self): client.transport_stop() - libseq.selectSong(5) - self.assertEqual(libseq.getSong(), 5) - libseq.clearSong(5) + libseq.selectScene(5) + self.assertEqual(libseq.getScene(), 5) + libseq.clearScene(5) self.assertEqual(libseq.getTracks(5), 0) self.assertEqual(libseq.addTrack(5), 0) self.assertEqual(libseq.addTrack(5), 1) @@ -643,13 +643,13 @@ def test_ai00_song(self): libseq.removeTrack(5,0) self.assertEqual(libseq.getTracks(5), 1) self.assertNotEqual(libseq.getSequence(5,0), 0) - libseq.clearSong(6) + libseq.clearScene(6) self.assertEqual(libseq.getTracks(6), 0) - libseq.copySong(5, 6) + libseq.copyScene(5, 6) self.assertEqual(libseq.getTracks(6), 1) - #TODO: Check content of copied song + #TODO: Check content of copied scene def test_ai01_timesig(self): - libseq.selectSong(5) + libseq.selectScene(5) self.assertEqual(libseq.getTimeSigAt(5, 0), 0x0404) # Default time signature should be 4/4 sleep(0.1) self.assertEqual(client.transport_state, jack.STOPPED) @@ -733,7 +733,7 @@ def test_ai02_tempo(self): client.transport_stop() sleep(0.1) self.assertEqual(client.transport_state, jack.STOPPED) - libseq.selectSong(5) + libseq.selectScene(5) self.assertEqual(libseq.getTempoAt(5, 1, 0), 120) # Default tempo should be 120 sequence = libseq.getSequence(1005,libseq.addTrack(1005)) libseq.clearSequence(sequence) @@ -789,7 +789,7 @@ def test_ai02_tempo(self): def test_ai02_tempo(self): global last_rx libseq.enableDebug() - libseq.selectSong(1) + libseq.selectScene(1) sleep(0.1) self.assertEqual(client.transport_state, jack.STOPPED) sequence = libseq.getSequence(1001,libseq.addTrack(1001)) @@ -824,6 +824,6 @@ def test_ai02_tempo(self): self.assertTrue(min_time < time2-time1 < max_time) ''' -# TOOO Check beat type, sendMidiXXX (or remove), isSongPlaying, getTriggerChannel, setTriggerChannel, getTriggerNote, setTriggerNote, setInputChannel, getInputChannel, setScale, getScale, setTonic, getTonic, setChannel, getChannel, setOutput, setTempo, getTempo, setSongPosition, getSongPosition, startSong, pauseSong, toggleSong, solo, transportXXX +# TOOO Check beat type, sendMidiXXX (or remove), isScenePlaying, getTriggerChannel, setTriggerChannel, getTriggerNote, setTriggerNote, setInputChannel, getInputChannel, setScale, getScale, setTonic, getTonic, setChannel, getChannel, setOutput, setTempo, getTempo, setScenePosition, getScenePosition, startScene, pauseScene, toggleScene, solo, transportXXX unittest.main() diff --git a/zynlibs/zynseq/zynseq.cpp b/zynlibs/zynseq/zynseq.cpp index c3bbf2378..33c46d372 100644 --- a/zynlibs/zynseq/zynseq.cpp +++ b/zynlibs/zynseq/zynseq.cpp @@ -1,10 +1,11 @@ + /* * ****************************************************************** * ZYNTHIAN PROJECT: Zynseq Library * * Library providing step sequencer as a Jack connected device * - * Copyright (C) 2020-2025 Brian Walton + * Copyright (C) 2020-2026 Brian Walton * * ****************************************************************** * @@ -24,16 +25,18 @@ */ #include // provides strcmp -#include #include #include #include +#include -#include // provides JACK interface -#include // provides JACK MIDI interface -#include // provides printf -#include // provides exit -#include // provides thread for timer +#include // provides JACK interface +#include // provides JACK MIDI interface +#include // provides printf +#include // provides exit +#include // provides thread for timer +#include // provides sqrt +#include // provides json #include "metronome.h" // metronome wav data #include "pattern.h" // provides pattern objects @@ -41,12 +44,13 @@ #include "timebase.h" // provides timebase event map #include "zynseq.h" // exposes library methods as c functions -#define FILE_VERSION 10 +#define FILE_VERSION 11 #define DPRINTF(fmt, args...) \ if (g_bDebug) \ fprintf(stderr, fmt, ##args) +// Structure to capture live recorded MIDI events struct ev_start { uint32_t start; uint8_t velocity; @@ -54,74 +58,74 @@ struct ev_start { }; static struct ev_start startEvents[128]; -jack_port_t* g_pInputPort; // Pointer to the JACK input port -jack_port_t* g_pOutputPort; // Pointer to the JACK output port +jack_port_t* g_pInputPort; // Pointer to the JACK MIDI input port +jack_port_t* g_pClockInputPort; // Pointer to the JACK MIDI clock input port +jack_port_t* g_pOutputPort; // Pointer to the JACK MIDI output port +jack_port_t* g_pClockOutputPort; // Pointer to the JACK MIDI clock output port +jack_port_t* g_pClippyOutputPort; // Pointer to the JACK MIDI output port feeding clippy jack_port_t* g_pMetronomePort; // Pointer to the JACK metronome audio output port jack_client_t* g_pJackClient = NULL; // Pointer to the JACK client -jack_nframes_t g_nSampleRate = 44100; // Quantity of samples per second -uint32_t g_nXruns = 0; +jack_nframes_t g_nSampleRate = 48000; // Quantity of samples per second +uint32_t g_nXruns = 0; +std::multimap g_mSchedule; // Schedule of sequence events (queue for sending), indexed by scheduled play time (ticks since tick epoch) SequenceManager g_seqMan; // Instance of sequence manager -uint32_t g_nPattern = 0; // Index of currently edited pattern -Sequence* g_pSequence = NULL; // Pattern editor sequence -std::multimap g_mSchedule; // Schedule of MIDI events (queue for sending), indexed by scheduled play time (samples since JACK epoch) -bool g_bMutex = false; // Mutex lock for access to g_mSchedule -bool g_bDebug = false; // True to output debug info -bool g_bPatternModified = false; // True if pattern has changed since last check -bool g_bDirty = false; // True if anything has been modified -size_t g_nPlayingSequences = 0; // Quantity of playing sequences -std::set g_setTransportClient; // Set of timebase clients having requested transport play -bool g_bClientPlaying = false; // True if any external client has requested transport play -bool g_bMidiRecord = false; // True to add notes to current pattern from MIDI input -uint8_t g_nSustainValue = 0; // Last sustain pedal value during note input (recording) -uint32_t g_nSustainStart = 0; // Step when sustain pedal was last pressed - -char g_sName[32]; // Buffer to hold sequence name so that it can be sent back for Python to parse -uint8_t g_nInputRest = 0xFF; // MIDI note number that creates rest in pattern -uint16_t g_nVerticalZoom = 16; // Quantity of rows to show in pattern and arranger view -uint16_t g_nHorizontalZoom = 16; // Quantity of beats to show in arranger view - -// PPQN => Clock resolution: Parts Per Quarter Note -#define PPQN_INTERNAL 96 -#define PPQN_MIDI 24 -uint32_t PPQN = PPQN_INTERNAL; +bool g_naHeldNote[16][128]; // Array of flags indicating a note has been played on a MIDI channel +uint8_t g_nScene = 0; // Index of currently selected scene +Pattern* g_pPattern = NULL; // Pointer to currently edited pattern +uint16_t g_nPhrase = 0; // Index of currently edited phrase +uint16_t g_nSequence = 0; // Index of currently edited sequence +bool g_bMutex = false; // Mutex lock for access to g_mSchedule +bool g_bDebug = false; // True to output debug info +bool g_bPatternModified = false; // True if pattern has changed since last check +bool g_bDirty = false; // True if anything has been modified +uint32_t g_nTransportClients = 0; // Bitwise flags indicating which clients have requested local transport +uint8_t g_nTransportState = STOPPED; // State of local (non-jack) transport +bool g_bTransportRolling = false; // True if (arranger) transport rolling forward bars +bool g_bMidiRecord = false; // True to add notes to current pattern from MIDI input +uint8_t g_nSustainValue = 0; // Last sustain pedal value during note input (recording) +uint32_t g_nSustainStart = 0; // Step when sustain pedal was last pressed +uint32_t g_nLastStepCC = 0; // Step when last => WARNING!! Doesn't work if capturing several CC at once! +uint8_t g_nPlayingSequences = 0; // Bitwise flga of playing/starting sequences + +char g_sName[256]; // Buffer to hold sequence name so that it can be sent back for Python to parse +uint8_t g_nInputRest = 0xFF; // MIDI note number that creates rest in pattern +uint16_t g_nVerticalZoom = 16; // Quantity of rows to show in pattern and arranger view +uint16_t g_nHorizontalZoom = 16; // Quantity of beats to show in arranger view + +// Patter copy/paste buffer +Pattern* g_pPatternBuffer = NULL; // Pointer to pattern copy/paste buffer // Transport variables apply to next period -uint32_t g_nBeatsPerBar = 4; -uint32_t g_nBeatType = 4; -uint32_t g_nTicksPerBeat = 1920; -uint32_t g_nTicksPerClock = g_nTicksPerBeat / PPQN; +uint32_t g_nDefaultBpb = DEFAULT_BPB; // Default quantity of beats (quater notes) in each bar +uint32_t g_nBeatsPerBar = DEFAULT_BPB; // Current quantity of beats (quater notes) in each bar (sync point division) +uint32_t g_nBeatType = 4; // Time signature denominator (not used) double g_dTempo = 120.0; +double g_dFramesPerTick; // Quantity of frames in each sequence clock cycle (tick) bool g_bTimebaseChanged = false; // True to trigger recalculation of timebase parameters Timebase* g_pTimebase = NULL; // Pointer to the timebase object for selected song -TimebaseEvent* g_pNextTimebaseEvent = NULL; // Pointer to the next timebase event or NULL if no more events in this song uint32_t g_nBar = 1; // Current bar uint32_t g_nBeat = 1; // Current beat within bar uint32_t g_nTick = 0; // Current tick within bar uint32_t g_nBarStartTick = 0; // Quantity of ticks from start of song to start of current bar -jack_nframes_t g_nTransportStartFrame = 0; // Quantity of frames from JACK epoch to transport start -std::queue> g_qClockPos; // Queue of pending clock positions relative to JACK epoch and clock duration in frames at this time -jack_nframes_t g_nFramesPerClock = getFramesPerClock(g_dTempo); // it should have 0.1% jitter at 1920 PPQN and much better jitter (0.01%) at current 24PPQN -uint16_t g_nClock = 0; // Quantity of clocks since start of beat -uint16_t g_nMidiClock = 0; // Quantity of *RECEIVED* MIDI clocks since start of beat -uint16_t g_nAnalogClock = 0; // Quantity of *RECEIVED* ANALOG clocks since start of beat -int8_t g_nAnalogClocksBeat = 1; // Number of analog clocks per beat (Analog Clock Divisor) -uint8_t g_nClockSource = TRANSPORT_CLOCK_INTERNAL; // Source of clock that progresses playback -bool g_bSendMidiClock = false; // True to send MIDI clock -jack_nframes_t g_nFramesSinceLastBeat = 0; // Quantity of frames since last beat - -float g_fSwingAmount = 0.0; // Swing amount, range from 0 to 1, but values over 0.5 are not "MPC swing" -float g_fHumanTime = 0.0; // Timing Humanization, range from 0 to FLOAT_MAX -float g_fHumanVelo = 0.0; // Velocity Humanization, range from 0 to FLOAT_MAX -float g_fPlayChance = 1.0; // Probability for playing notes (0 = Notes are not played, 0.5 = Notes plays with prob.50%, 1 = All notes play always) - -size_t g_nMetronomePtr = -1; // Position within metronome click wav data -float g_fMetronomeLevel = 1.0; // Factor to scale metronome level (volume) -bool g_bMetronome = false; // True to enable metronome +uint32_t g_nExtClockPPQN = PPQN_MIDI; // Quantity of PPQN of the external clock + +float g_fSwingAmount = 0.0; // Swing amount, range from 0 to 1, but values over 0.5 are not "MPC swing" +float g_fHumanTime = 0.0; // Timing Humanization, range from 0 to FLOAT_MAX +float g_fHumanVelo = 0.0; // Velocity Humanization, range from 0 to FLOAT_MAX +float g_fPlayChance = 1.0; // Probability for playing notes (0 = Notes are not played, 0.5 = Notes plays with prob.50%, 1 = All notes play always) + +size_t g_nMetronomePtr = -1; // Position within metronome click wav data (-1 if not playing, e.g. between beats) +float g_fMetronomeLevel = 1.0; // Factor to scale metronome level (volume) +uint8_t g_nMetronomeMode = 0; // Metonome play mode struct metro_wav_t g_metro_pip; struct metro_wav_t g_metro_peep; struct metro_wav_t* g_pMetro = &g_metro_pip; // Pointer to the current metronome sound (pip/peep) +char* g_pState = nullptr; // Pointer used for temporary transfer of state string + +using json = nlohmann::ordered_json; + // ** Internal (non-public) functions (not delcared in header so need to be in correct order in source file) ** // Enable / disable debug output @@ -130,199 +134,31 @@ void enableDebug(bool bEnable) { g_bDebug = bEnable; } -// Convert tempo to frames per tick -double getFramesPerTick(double dTempo) { - //!@todo Be cosistent in use of ticks or clocks - return 60.0 * g_nSampleRate / (dTempo * g_nTicksPerBeat); +// Convert tempo to frames per clock +void updateClockTiming() { + g_dFramesPerTick = 60.0 * g_nSampleRate / (g_dTempo * PPQN_INTERNAL); } -// Convert tempo to frames per clock -jack_nframes_t getFramesPerClock(double dTempo) { - //printf("getFramesPerClock(%f) = %f * %u = %u\n", dTempo, getFramesPerTick(dTempo), g_nTicksPerClock, jack_nframes_t(getFramesPerTick(dTempo) * g_nTicksPerClock)); - return jack_nframes_t(getFramesPerTick(dTempo) * g_nTicksPerClock); -} - -void setPPQN(uint32_t ppqn) { - PPQN = ppqn; - g_nTicksPerClock = g_nTicksPerBeat / PPQN; - g_nFramesPerClock = getFramesPerClock(g_dTempo); -} - -// Update bars, beats, ticks for given position in frames -void updateBBT(jack_position_t* position) { - //!@todo Populate bbt_sequence (experimental so not urgent but could be useful) - jack_nframes_t nFrames = 0; - jack_nframes_t nFramesPerTick = getFramesPerTick(g_dTempo); //!@todo Need to use default tempo from start of song but current tempo now!!! - uint32_t nBar = 0; - uint32_t nBeat = 0; - uint32_t nTick = 0; - uint8_t nBeatsPerBar = 4; - uint32_t nTicksPerBar = g_nTicksPerBeat * nBeatsPerBar; - bool bDone = false; - jack_nframes_t nFramesInSection; - uint32_t nTicksInSection; - uint32_t nTicksFromStart = 0; - - position->tick = position->frame % uint32_t(nFramesPerTick); - position->beat = (uint32_t(position->frame / nFramesPerTick) % uint32_t(g_nTicksPerBeat)) + 1; - position->bar = (uint32_t(position->frame / nFramesPerTick / g_nTicksPerBeat) % nBeatsPerBar) + 1; - position->beats_per_bar = g_nBeatsPerBar; - position->beats_per_minute = g_dTempo; - position->beat_type = g_nBeatType; - position->ticks_per_beat = g_nTicksPerBeat; - position->bar_start_tick = 0; //!@todo Need to calculate this - // g_pNextTimebaseEvent = g_pTimebase->getPreviousTimebaseEvent(position->bar, (position->beat - 1) * position->ticks_per_beat + position->tick , - // TIMEBASE_TYPE_ANY); - - // Iterate through events, calculating quantity of frames between each event - /* - if(g_pTimebase) - { - for(size_t nIndex = 0; nIndex < g_pTimebase->getEventQuant(); ++nIndex) - { - // Get next event - TimebaseEvent* pEvent = g_pTimebase->getEvent(nIndex); - // Calculate quantity of ticks between events and frames between events - nTicksInSection = (pEvent->bar * nTicksPerBar + pEvent->clock * g_nFramesPerClock - nTicksFromStart); - nFramesInSection = nTicksInSection * nFramesPerTick; - // Break if next event is beyond requested position - if(nFrames + nFramesInSection > position->frame) - break; - // Update frame counter, bar and tick from which to count last section - nFrames += nFramesInSection; - nBar = pEvent->bar; - nTick = pEvent->clock * g_nTicksPerClock; - nTicksFromStart += nTicksInSection; - // Update tempo and time signature from event - if(pEvent->type == TIMEBASE_TYPE_TEMPO) - nFramesPerTick = getFramesPerTick(pEvent->value); - else if(pEvent->type == TIMEBASE_TYPE_TIMESIG) - { - nBeatsPerBar = pEvent->value >> 8; - nBeatsType = pEvent->value & 0x00FF; - nTicksPerBar = g_nTicksPerBeat * nBeatsPerBar; - } - } +void onJackConnect(jack_port_id_t source, jack_port_id_t dest, int connect, void* args) { + if (jack_port_by_id(g_pJackClient, dest) == g_pClockInputPort) { + setTempo(g_dTempo); + DPRINTF("%u connections to MIDI clock port\n", jack_port_connected(g_pClockInputPort)); } - */ - // Calculate BBT from last section - nFramesInSection = position->frame - nFrames; - nTicksInSection = nFramesInSection / nFramesPerTick; - uint32_t nBarsInSection = nTicksInSection / nTicksPerBar; - position->bar = nBar + nBarsInSection + 1; - uint32_t nTicksInLastBar = nTicksInSection % nTicksPerBar; - position->beat = nTicksInLastBar / g_nTicksPerBeat + 1; - position->tick = nTicksInLastBar % position->beat; - nTicksFromStart += nTicksInSection; - position->bar_start_tick = nTicksFromStart - nTicksInLastBar; - g_nClock = position->tick % (uint32_t)g_nTicksPerClock; - // g_dTempo = g_pTimebase->getTempo(g_nBar, (g_nBeat * g_nTicksPerBeat + g_nTick) / g_nTicksPerClock); - // g_nBeatsPerBar = uint32_t(g_pTimebase->getTimeSig(g_nBar, (g_nBeat * g_nTicksPerBeat + g_nTick) / g_nTicksPerClock)) >> 8; -} - -/* Handle timebase callback - update timebase elements (BBT) from transport position - nState: Current jack transport state - nFramesInPeriod: Quantity of frames in current period - pPosition: Pointer to position structure for the next cycle - bUpdate: True (non-zero) to request position be updated to position defined in pPosition (also true on first callback) - pArgs: Pointer to argument supplied by jack_set_timebase_callback (not used here) - - [Info] - If bUpdate is false then calculate BBT from pPosition->frame: quantity of frames from start of song. - If bUpdate is true then calculate pPostion-frame from BBT info +} - [Process] - Calculate bars, beats, ticks at pPosition->frame from start of song or calculate frame from BBT: - Iterate through timebase events spliting song into sections delimited by timebase events: time signature / tempo changes, calculating BBT for each section - up to current position. Add events from sequences to schedule -*/ +// Handle timebase change void onJackTimebase(jack_transport_state_t nState, jack_nframes_t nFramesInPeriod, jack_position_t* pPosition, int bUpdate, void* pArgs) { - // Process timebase events - /* Disabled timebase events until linear song implemented - while(g_pTimebase && g_pNextTimebaseEvent && (g_pNextTimebaseEvent->bar <= g_nBar)) // || g_pNextTimebaseEvent->bar == g_nBar && g_pNextTimebaseEvent->clock - <= g_nClock)) - { - if(g_pNextTimebaseEvent->type == TIMEBASE_TYPE_TEMPO) - { - g_dTempo = g_pNextTimebaseEvent->value; - g_nFramesPerClock = getFramesPerClock(g_dTempo); - pPosition->beats_per_minute = g_dTempo; - g_bTimebaseChanged = true; - DPRINTF("Tempo change to %0.0fbpm frames/clk: %f\n", g_dTempo, g_nFramesPerClock); - } - else if(g_pNextTimebaseEvent->type == TIMEBASE_TYPE_TIMESIG) - { - g_nBeatsPerBar = g_pNextTimebaseEvent->value >> 8; - g_nBeatType = g_pNextTimebaseEvent->value & 0x0F; - pPosition->beats_per_bar = g_nBeatsPerBar; - g_bTimebaseChanged = true; - DPRINTF("Time signature change to %u/%u\n", g_nBeatsPerBar, g_nBeatType); - } - g_pNextTimebaseEvent = g_pTimebase->getNextTimebaseEvent(g_pNextTimebaseEvent); - } - */ + pPosition->bar = g_nBar; + pPosition->beat = g_nBeat; + pPosition->tick = g_nTick; + pPosition->bar_start_tick = g_nBarStartTick; + pPosition->beats_per_minute = g_dTempo; + pPosition->beats_per_bar = g_nBeatsPerBar; + pPosition->ticks_per_beat = PPQN_INTERNAL; + pPosition->valid = JackPositionBBT; +} - // Calculate BBT at start of next period if transport starting, locating or change in tempo or timebase (although latter is commented out) - if (bUpdate || g_bTimebaseChanged) { - /* - if(g_pTimebase) - { - g_dTempo = g_pTimebase->getTempo(g_nBar, (g_nBeat * g_nTicksPerBeat + g_nTick)); - g_nBeatsPerBar = g_pTimebase->getTimeSig(g_nBar, (g_nBeat * g_nTicksPerBeat + g_nTick)) >> 8; - } - */ - // Update position based on parameters passed - if (pPosition->valid & JackPositionBBT) { - // Set position from BBT - DPRINTF("bUpdate: %s, g_bTimebaseChanged: %s, Position valid flags: %u\n", bUpdate ? "True" : "False", g_bTimebaseChanged ? "True" : "False", - pPosition->valid); - DPRINTF("PreSet position from BBT Bar: %u Beat: %u Tick: %u Clock: %u\n", pPosition->bar, pPosition->beat, pPosition->tick, g_nClock); - DPRINTF("Beats per bar: %f Tempo: %f\n", pPosition->beats_per_bar, g_dTempo); - // Fix overruns - pPosition->beat += pPosition->tick / (uint32_t)pPosition->ticks_per_beat; - pPosition->tick %= (uint32_t)(pPosition->ticks_per_beat); - pPosition->bar += (pPosition->beat - 1) / pPosition->beats_per_bar; - pPosition->beat = ((pPosition->beat - 1) % (uint32_t)(pPosition->beats_per_bar)) + 1; - pPosition->frame = transportGetLocation(pPosition->bar, pPosition->beat, pPosition->tick); - pPosition->ticks_per_beat = g_nTicksPerBeat; - pPosition->beats_per_minute = g_dTempo; //!@todo Need to set tempo from position pointer to allow external clients to set tempo - g_nClock = pPosition->tick / g_nTicksPerClock; - g_nBar = pPosition->bar; - g_nBeat = pPosition->beat; - g_nTick = pPosition->tick; - DPRINTF("Set position from BBT Bar: %u Beat: %u Tick: %u Clock: %u\n", pPosition->bar, pPosition->beat, pPosition->tick, g_nClock); - } else // if(!bUpdate) //!@todo I have masked bUpdate because I don't see why we would be reaching here but we do and need to figure out why - { - updateBBT(pPosition); - DPRINTF("Set position from frame %u\n", pPosition->frame); - } - g_nTransportStartFrame = jack_frame_time(g_pJackClient) - pPosition->frame; //!@todo This isn't setting to transport start position - pPosition->valid = JackPositionBBT; - g_nFramesPerClock = getFramesPerClock(g_dTempo); - g_bTimebaseChanged = false; - DPRINTF("New position: Jack frame: %u Frame: %u Bar: %u Beat: %u Tick: %u Clock: %u\n", g_nTransportStartFrame, pPosition->frame, pPosition->bar, - pPosition->beat, pPosition->tick, g_nClock); - //!@todo Check impact of timebase discontinuity - } else { - // DPRINTF("Update position with values from previous period Jack frame: %u Frame: %u Bar: %u Beat: %u Tick: %u Clock: %u\n", g_nTransportStartFrame, - // pPosition->frame, pPosition->bar, pPosition->beat, pPosition->tick, g_nClock); - // Set BBT values calculated during previous period - pPosition->bar = g_nBar; - pPosition->beat = g_nBeat; - pPosition->tick = g_nTick % (uint32_t)g_nTicksPerBeat; - pPosition->bar_start_tick = g_nBarStartTick; - pPosition->beats_per_bar = g_nBeatsPerBar; - pPosition->beat_type = g_nBeatType; - pPosition->ticks_per_beat = g_nTicksPerBeat; - pPosition->beats_per_minute = g_dTempo; - // Loop frame if not playing song - // if(!g_nBeat && isSongPlaying()) - // pPosition->frame = transportGetLocation(pPosition->bar, pPosition->beat, pPosition->tick); //!@todo Does this work? (yes). Are there any - // discontinuity or impact on other clients? Can it be optimsed? - } -} - -/* Process jack cycle - must complete within single jack period +/* Process jack period nFrames: Quantity of frames in this period pArgs: Parameters passed to function by main thread (not used here) @@ -333,122 +169,121 @@ void onJackTimebase(jack_transport_state_t nState, jack_nframes_t nFramesInPerio [Process] Process incoming MIDI events Iterate through events scheduled to trigger within this process period - For each event, add MIDI events to the output buffer at appropriate sample sequence + For each event, add MIDI events to the output buffer at appropriate frame offset Remove events from schedule + + Schedule holds events, indexed by their scheduled execution time in frames since jack epoch. */ int onJackProcess(jack_nframes_t nFrames, void* pArgs) { - static jack_nframes_t nLastBeatFrame = 0; // Frames since jack epoch of last quarter note used to calc tempo of external clock - static std::pair lastClock; - - jack_nframes_t nNow = jack_last_frame_time(g_pJackClient); - jack_position_t transportPosition; // JACK transport position structure populated each cycle and checked for transport progress - jack_transport_state_t nState = jack_transport_query(g_pJackClient, &transportPosition); - - // Metronome output buffer + // Transport & Clock + static uint64_t nNow = 0; + static jack_nframes_t nLastNow32 = 0; + static uint64_t nLastExtClockFrame = 0; // Frames since jack epoch of last external clock + static double dNextIntClockFrame = 0.0; // Frames since jack epoch of next internal clock + static uint32_t nExtClk = 0; // Count of external clocks in this beat (wrap at PPQN) + static uint32_t nTickTime = 0; // Quantity of elapsed ticks since tick epoch that next event will be processed + static uint32_t nBeatsPerBar = g_nBeatsPerBar; // Sequencer's live beats per bar, updated from g_nBeatsPerBar on bar boundary + static int64_t nNextBeatTime = 0; // Tick time of next beat + static bool bRolling = g_bTransportRolling; // Transport rolling bars, updates g_bTranportRolling on next bar + + // Populate 64-bit monotonic frame clock (to avoid 24 hour overflow) + jack_nframes_t nNow32 = jack_last_frame_time(g_pJackClient); + if (nNow32 < nLastNow32) + nNow += 0x100000000ULL; + nNow = (nNow & 0xFFFFFFFF00000000ULL) | nNow32; + + // Metronome audio output buffer jack_default_audio_sample_t* pOutMetronome = (jack_default_audio_sample_t*)jack_port_get_buffer(g_pMetronomePort, nFrames); memset(pOutMetronome, 0, sizeof(jack_default_audio_sample_t) * nFrames); - // Get output buffer that will be processed in this process cycle + // MIDI output buffers void* pOutputBuffer = jack_port_get_buffer(g_pOutputPort, nFrames); - unsigned char* pBuffer; + void* pClockBuffer = jack_port_get_buffer(g_pClockOutputPort, nFrames); + void* pClippyBuffer = jack_port_get_buffer(g_pClippyOutputPort, nFrames); jack_midi_clear_buffer(pOutputBuffer); + jack_midi_clear_buffer(pClockBuffer); + jack_midi_clear_buffer(pClippyBuffer); + + // Get mutex lock to protect access to MIDI output schedule + while (g_bMutex) + std::this_thread::sleep_for(std::chrono::microseconds(10)); + g_bMutex = true; + + std::vector vTicks; // Vector of internal tick offsets within this jack period // Process MIDI input - void* pInputBuffer = jack_port_get_buffer(g_pInputPort, nFrames); jack_midi_event_t midiEvent; - jack_nframes_t nCount = jack_midi_get_event_count(pInputBuffer); - Pattern* pPattern = g_seqMan.getPattern(g_nPattern); - uint8_t bPatternRecording = (g_bMidiRecord && g_pSequence && pPattern); - // Track* pTrack = g_pSequence->getTrack(g_pSequence->m_nCurrentTrack); + void* pInputBuffer; - //enableDebug(true); - //printf("ZYNSEQ PARAMS => PPQN= %u, CLOCK SOURCE= %u, FramesClock= %u, ClocksPerStep= %u, BeatType= %u, BeatsBar= %u\n", PPQN, g_nClockSource, g_nFramesPerClock, pPattern->getClocksPerStep(), g_nBeatType, g_nBeatsPerBar); - //printf("ZYNSEQ TRANSPORT => %u clock: %u beat: %u tick: %u\n", nNow, g_nClock, g_nBeat, g_nTick); + // Ensure next clock frame is not in the past + if (dNextIntClockFrame < nNow) + dNextIntClockFrame = nNow; - while (g_bMutex) - std::this_thread::sleep_for(std::chrono::microseconds(10)); - g_bMutex = true; - for (jack_nframes_t i = 0; i < nCount; i++) { + // MIDI Clock input + pInputBuffer = jack_port_get_buffer(g_pClockInputPort, nFrames); + for (jack_nframes_t i = 0; i < jack_midi_get_event_count(pInputBuffer); ++i) { if (jack_midi_event_get(&midiEvent, pInputBuffer, i)) continue; + switch (midiEvent.buffer[0]) { + case MIDI_CLOCK: { + uint32_t nExpectedTicksBeforeClk = midiEvent.time / g_dFramesPerTick; + // First update tempo to get current clock period + double dTempo = 60.0 * g_nSampleRate / (double(g_nExtClockPPQN) * (nNow + midiEvent.time - nLastExtClockFrame)); + setTempo(dTempo); + nLastExtClockFrame = nNow + midiEvent.time; + uint32_t nTicksBeforeClk = midiEvent.time / g_dFramesPerTick; + int32_t nTickDelta = nTicksBeforeClk - nExpectedTicksBeforeClk; + nNextBeatTime += nTickDelta; + break; + } + case MIDI_START: { + // Rx start on clock port so restart any playing sequences - this may cause disruption to playback - as expected + g_nBar = 1; + g_nBeat = 1; + nExtClk = 0; + g_nBarStartTick = 0; + fprintf(stderr, "START\n"); + break; + } + case MIDI_POSITION: { + // Rx song position on clock port - reset to bar boundary, e.g. used by bar clock signal + uint16_t pos = midiEvent.buffer[1] + (midiEvent.buffer[2] << 7); + if (pos == 0) { + fprintf(stderr, "MIDI SONG POSITION %u\n", pos); + g_nBeat = 1; + } + break; + } + } + } + + // Populate remaining ticks in this period, at current tempo + for (; dNextIntClockFrame < nNow + nFrames; dNextIntClockFrame += g_dFramesPerTick) { + vTicks.push_back(dNextIntClockFrame - nNow); + } + + // Process normal MIDI input (ignore MIDI CLOCK) + pInputBuffer = jack_port_get_buffer(g_pInputPort, nFrames); + jack_nframes_t nCount = jack_midi_get_event_count(pInputBuffer); + uint8_t bPatternRecording = (g_bMidiRecord && g_pPattern); + for (jack_nframes_t i = 0; i < nCount; i++) { + if (jack_midi_event_get(&midiEvent, pInputBuffer, i)) + continue; // Process MIDI RT-events => clock/tranport events - if (g_nClockSource & (TRANSPORT_CLOCK_MIDI | TRANSPORT_CLOCK_ANALOG)) { - switch (midiEvent.buffer[0]) { - /* + switch (midiEvent.buffer[0]) { case MIDI_STOP: + // Rx stop on any port - stops transport rolling on next bar + bRolling = false; break; - */ case MIDI_START: - g_nBar = 1; - g_bMutex = false; - transportStart("zynseq"); - while (g_bMutex) - std::this_thread::sleep_for(std::chrono::microseconds(10)); - g_bMutex = true; - nState = JackTransportRolling; - g_nClock = 0; - g_nMidiClock = 0; - g_nAnalogClock = 0; - nLastBeatFrame = 0; - g_nBeat = 1; + // Rx start on any port - starts transport rolling on next bar + bRolling = true; //!@todo Use bRolling to acutally start rolling + //!@todo reset to start of bar break; case MIDI_CONTINUE: - g_bMutex = false; - transportStart("zynseq"); - while (g_bMutex) - std::this_thread::sleep_for(std::chrono::microseconds(10)); - g_bMutex = true; - nState = JackTransportRolling; - break; - case MIDI_CLOCK: - if (g_nClockSource & TRANSPORT_CLOCK_MIDI) { - // DPRINTF("MIDI CLOCK %u, %u => %u\n", g_nMidiClock, g_nClock, midiEvent.time); - if (g_nMidiClock == 0) { - // Update tempo on each beat - if (nLastBeatFrame) { - double new_tempo = 60.0 * (double)g_nSampleRate / (nNow + midiEvent.time - nLastBeatFrame); - if (new_tempo > 20.0) setTempo(new_tempo); - } - // DPRINTF("BPM = 60 * %u / (%u + %u - %u) = %f\n", g_nSampleRate, nNow, midiEvent.time, nLastBeatFrame, 60.0 * (double)g_nSampleRate / - // (nNow + midiEvent.time - nLastBeatFrame)); - nLastBeatFrame = nNow + midiEvent.time; - } - if (nState == JackTransportRolling) - g_qClockPos.push(std::pair(nNow + midiEvent.time, g_nFramesPerClock)); - // PPQN is fixed to 24 in MIDI 1.0 - if (g_nMidiClock < (PPQN_MIDI - 1)) - g_nMidiClock++; - else - g_nMidiClock = 0; - } - // For analog clock source => update tempo on each analog clock - else if (g_nClockSource & TRANSPORT_CLOCK_ANALOG) { - if (nLastBeatFrame) - setTempo(60.0 * (double)g_nSampleRate / (g_nAnalogClocksBeat * (nNow + midiEvent.time - nLastBeatFrame))); - //printf("BPM = 60 * %u / (%u * (%u + %u - %u)) = %f\n", g_nSampleRate, g_nAnalogClocksBeat, nNow, midiEvent.time, nLastBeatFrame, 60.0 * (double)g_nSampleRate / (g_nAnalogClocksBeat * (nNow + midiEvent.time - nLastBeatFrame))); - nLastBeatFrame = nNow + midiEvent.time; - - // Adjust time of next clock in queue, so it keep aligned with analog pulse - if (!g_qClockPos.empty()) { - uint16_t target_clock = (g_nAnalogClock * PPQN / g_nAnalogClocksBeat) % PPQN; - //printf("Clock => %u, Target Clock => %u\n", g_nClock, target_clock); - // Analog clock is advanced => Move next clock in queue to Now - if (g_nClock > target_clock) { - g_nClock = target_clock; - g_qClockPos.back().first = nLastBeatFrame; - //printf("Next Clock advanced to %lu\n", g_qClockPos.back().first); - } - // Analog clock is delayed => Delay next clock in queue - else if (g_nClock < target_clock) { - g_nClock = target_clock; - g_qClockPos.back().first = nLastBeatFrame + g_nFramesPerClock; - //printf("Next Clock delayed to %lu\n", g_qClockPos.back().first); - } - } - g_nAnalogClock ++; - if (g_nAnalogClock >= g_nAnalogClocksBeat) g_nAnalogClock = 0; - } + // Rx continue on any port - starts jack transport on next bar + bRolling = true; break; /* case MIDI_POSITION: @@ -458,19 +293,23 @@ int onJackProcess(jack_nframes_t nFrames, void* pArgs) { DPRINTF("StepJackClient POSITION %d (clocks)\n", nPos); break; } - case MIDI_SONG: - DPRINTF("StepJackClient Select song %d\n", midiEvent.buffer[1]); - break; */ - default: + case MIDI_SONG: { + // MIDI song selection will change selected sequencer scene + uint8_t nSong = midiEvent.buffer[1]; + DPRINTF("StepJackClient Select song %u\n", nSong); + if (nSong < g_seqMan.getNumScenes()) + setScene(nSong); //!@todo Restricted to existing scenes but may want to allow creating new scene break; } + default: + break; } // Handle MIDI events for programming patterns from MIDI input if (bPatternRecording) { - uint32_t nStep = getPatternPlayhead(); - uint8_t nPlayState = g_pSequence->getPlayState(); + uint32_t nStep = getPatternPlayhead(); + uint8_t nPlayState = g_seqMan.getSequence(g_nScene, g_nPhrase, g_nSequence)->getPlayState(); uint8_t nCommand = midiEvent.buffer[0] & 0xF0; uint8_t nNum1 = midiEvent.buffer[1]; uint8_t nNum2 = midiEvent.buffer[2]; @@ -479,45 +318,39 @@ int onJackProcess(jack_nframes_t nFrames, void* pArgs) { if (nPlayState) { // Note on event if (nCommand == MIDI_NOTE_ON && nNum2 > 0) { - int32_t fpos = int32_t(nNow + midiEvent.time) - lastClock.first - nFrames; // Frames from last clock to current event minus the latency delay (1 period = nFrames) - double dclk = double(fpos) / double(g_nFramesPerClock); // Frames to clocks (fraction of clock) - //printf("NOTE ON Clock Fraction => %d / %u = %f)\n", fpos, g_nFramesPerClock, dclk); - // Clocks from start of sequence until this event - double pos_clocks = double(g_pSequence->getPlayPosition()) + dclk; - // Calculate position offset in steps (from 0.0 to 1.0) - float offset = pos_clocks / double(pPattern->getClocksPerStep()) - double(nStep); - - if (offset < 0.0) offset = 0; - /* Currently can't set negative offset => it would be nice - if (offset > 0.5) { - nStep = (nStep + 1) % pPattern->getSteps(); - offset = 1.0 - offset; - }*/ - startEvents[nNum1].start = nStep; + // Current event time minus the latency delay (1 period = nFrames), converted to clocks + int fpos = int(midiEvent.time) - nFrames; + double dclk = double(fpos) / g_dFramesPerTick; + uint32_t nPlayPos = g_seqMan.getSequence(g_nScene, g_nPhrase, g_nSequence)->getPlayPosition() + int(dclk); + //fprintf(stderr, "START NOTE %d => %d (DCLK = %f)\n", nNum1, nPlayPos, dclk); + startEvents[nNum1].start = nPlayPos; startEvents[nNum1].velocity = nNum2; - startEvents[nNum1].offset = offset; } // Note off event else if ((nCommand == MIDI_NOTE_ON && nNum2 == 0) || nCommand == MIDI_NOTE_OFF) { - if (startEvents[nNum1].start != -1) { - int32_t fpos = int32_t(nNow + midiEvent.time) - lastClock.first - nFrames; // Frames from last clock to current event minus the latency delay (1 period = nFrames) - double dclk = double(fpos) / double(g_nFramesPerClock); // Frames to clocks (fraction of clock) - //printf("NOTE OFF Clock Fraction => %d / %u = %f)\n", fpos, g_nFramesPerClock, dclk); - // Clocks from start of sequence until this event - double pos_clocks = double(g_pSequence->getPlayPosition()) + dclk; - // Calculate duration from note start, in number of clocks - double dDur = pos_clocks - (double(startEvents[nNum1].start) + startEvents[nNum1].offset) * pPattern->getClocksPerStep(); - // If duration is negative => note crossed sequence end => fix it! - if (dDur < 0.0) dDur += pPattern->getLength(); - if (dDur < 1.0) dDur = 1.0; - // Calculate duration in steps - dDur /= pPattern->getClocksPerStep(); - // Add note to pattern - pPattern->addNote(startEvents[nNum1].start, nNum1, startEvents[nNum1].velocity, dDur, startEvents[nNum1].offset); + if (startEvents[nNum1].start != -1) { + // Current event time minus the latency delay (1 period = nFrames), converted to clocks + int fpos = int(midiEvent.time) - nFrames; + double dclk = double(fpos) / g_dFramesPerTick; + uint32_t nPlayPos = g_seqMan.getSequence(g_nScene, g_nPhrase, g_nSequence)->getPlayPosition() + int(dclk); + //fprintf(stderr, "END NOTE %d => %d (DCLK = %f)\n", nNum1, nPlayPos, dclk); + uint32_t nClocksPerStep = g_pPattern->getClocksPerStep(); + uint32_t nStart = startEvents[nNum1].start / nClocksPerStep; + float fOffset = double(startEvents[nNum1].start % nClocksPerStep) / nClocksPerStep; + float fDuration = double(int(nPlayPos) - int(startEvents[nNum1].start)) / nClocksPerStep; + // Constrain duration + if (fDuration < 0.0) + fDuration += g_pPattern->getSteps(); + if (fDuration < 1.0) + fDuration = 1.0; + + // Add note to pattern + g_pPattern->addNote(nStart, nNum1, startEvents[nNum1].velocity, fDuration, fOffset); + //fprintf(stderr, "Captured Note %d at %d + %f with duration %f\n", nNum1, nStart, fOffset, fDuration); // Reset note in event buffer startEvents[nNum1].start = -1; // Flag pattern as modified - setPatternModified(pPattern, true, false); + setPatternModified(g_pPattern, true, false); } } // CC event @@ -527,25 +360,30 @@ int onJackProcess(jack_nframes_t nFrames, void* pArgs) { if (nNum2 > 0 && g_nSustainValue == 0) { g_nSustainValue = nNum2; g_nSustainStart = nStep; - // Add pedal press - pPattern->addControl(g_nSustainStart, 64, g_nSustainValue, g_nSustainValue); - setPatternModified(pPattern, true, false); + // Add new pedal press + g_pPattern->addControl(g_nSustainStart, 64, g_nSustainValue, g_nSustainValue); + setPatternModified(g_pPattern, true, false); } else if (nNum2 == 0) { if (g_nSustainValue > 0) { // Add pedal release - pPattern->addControl(nStep, 64, 0, 0); - setPatternModified(pPattern, true, false); + g_pPattern->addControl(nStep, 64, 0, 0); // The next should be improved to be functional! - // Remove old pedals inside the new one => "Overdubbing" sustain pedal is a mess! - //pPattern->removeControlInterval(g_nSustainStart + 1, nStep - 1, 64); + // Remove old pedals => "Overdubbing" sustain pedal is a mess! + //g_pPattern->removeControlInterval(0, g_pPattern->getSteps() - 1, 64); + setPatternModified(g_pPattern, true, false); } g_nSustainValue = 0; } // else => Other cases must be bouncing or pedal "artifacts" that we ignore - // Manage rest of CCs + // Manage rest of CCs } else { - pPattern->addControl(nStep, nNum1, nNum2, nNum2); - setPatternModified(pPattern, true, false); + // Remove old CCs => "Overdubbing" CC is a mess! + if (g_nLastStepCC < nStep) + g_pPattern->removeControlInterval(g_nLastStepCC + 1, nStep, nNum1); + // Add new CC event + g_pPattern->addControl(nStep, nNum1, nNum2, nNum2); + g_nLastStepCC = nStep; + setPatternModified(g_pPattern, true, false); } } } @@ -558,28 +396,28 @@ int onJackProcess(jack_nframes_t nFrames, void* pArgs) { g_nSustainValue = nNum2; else { g_nSustainValue = 0; - bAdvance = true; + bAdvance = true; } } // Note on event else if (nCommand == MIDI_NOTE_ON && nNum2) { - setPatternModified(pPattern, true, false); + setPatternModified(g_pPattern, true, false); uint32_t nDuration = getNoteDuration(nStep, nNum1); if (g_nSustainValue > 0) - pPattern->addNote(nStep, nNum1, nNum2, nDuration + 1); + g_pPattern->addNote(nStep, nNum1, nNum2, nDuration + 1); else { bAdvance = true; if (nDuration) - pPattern->removeNote(nStep, nNum1); + g_pPattern->removeNote(nStep, nNum1); else if (nNum1 != g_nInputRest) - pPattern->addNote(nStep, nNum1, nNum2, 1); + g_pPattern->addNote(nStep, nNum1, nNum2, 1); } } // Advance step - if (bAdvance && nState != JackTransportRolling) { - if (++nStep >= pPattern->getSteps()) + if (bAdvance && g_nTransportState != PLAYING) { + if (++nStep >= g_pPattern->getSteps()) nStep = 0; - g_pSequence->setPlayPosition(nStep * getClocksPerStep()); + g_seqMan.getSequence(g_nScene, g_nPhrase, g_nSequence)->setPlayPosition(nStep * g_pPattern->getClocksPerStep()); // printf("libzynseq advancing to step %d\n", nStep); } } @@ -594,143 +432,185 @@ int onJackProcess(jack_nframes_t nFrames, void* pArgs) { // Send MIDI output aligned with first sample of frame resulting in similar latency to audio //!@todo Interpolate events across frame, e.g. CC variations - //if (g_qClockPos.size() > 1) printf("Queued clocks = %d!!\n", g_qClockPos.size()); - - // Iterate through clocks in this period, adding any events and handling any timebase changes - if (nState == JackTransportRolling) { - bool bSync = false; // True if at start of bar - jack_nframes_t nClockOffset = 0; // Position within this period that clock 0 occurs - // There should always be a clock scheduled for internal clock source when transport is rolling - if (g_nClockSource & TRANSPORT_CLOCK_INTERNAL && g_qClockPos.empty()) - g_qClockPos.push(std::pair(nNow, g_nFramesPerClock)); - // Process clock - while (!g_qClockPos.empty() && (g_qClockPos.front().first < nNow + nFrames)) { - //printf("PROCESSING CLOCK => %u < %u + %u = %u\n", g_qClockPos.front().first, nNow, nFrames, nNow + nFrames); - bSync = false; - if (g_nClock == 0) { - // Clock zero so on beat - bSync = (g_nBeat == 1); - g_nTick = 0; //!@todo ticks are not updated under normal rolling condition - g_pMetro = bSync ? &g_metro_peep : &g_metro_pip; - g_nMetronomePtr = 0; - nClockOffset = g_qClockPos.front().first - nNow; + // Process clock ticks in this period + jack_nframes_t nMetronomeFrame = 0; // Position within this period of next metronome sample + uint32_t nPeriodStartTick = nTickTime; // Store the first tick of this period + for (const auto& nFrame: vTicks) { + // Iterate clocks within this jack period to prepare MIDI output schedule events + + /* Schedule events in this period + Pass clock time and schedule to pattern manager so it can populate with events. + Pass sync pulse so that it can synchronise its sequences, e.g. start zynpad sequences + */ + + bool bBeat = false; // True if start of beat + bool bSync = false; // True if at start of bar + + // Update local (internal) transport + if (g_nTransportState == STARTING) { + g_nTransportState = PLAYING; + nNextBeatTime = nTickTime + PPQN_INTERNAL; + g_nBeat = 1; + bSync = true; + bBeat = true; + jack_transport_start(g_pJackClient); + } else if (g_nTransportState == STOPPING) { + if (g_nBeat == 1) { + g_nTransportState = STOPPED; + jack_transport_stop(g_pJackClient); + jack_transport_locate(g_pJackClient, 0); } - // Schedule events in next period - // Pass clock time and schedule to pattern manager so it can populate with events. Pass sync pulse so that it can synchronise its sequences, e.g. - // start zynpad sequences - g_nPlayingSequences = - g_seqMan.clock(g_qClockPos.front(), &g_mSchedule, bSync); //!@todo Optimise to reduce rate calling clock especially if we increase the clock - //!rate from 24 to 96 or above. Maybe return the time until next check - // Advance clock - if (++g_nClock >= PPQN) { - g_nClock = 0; - if (++g_nBeat > g_nBeatsPerBar) { + } + + if (g_nTransportState == PLAYING) { + if (nTickTime >= nNextBeatTime) { + // Beat + nNextBeatTime = nTickTime + PPQN_INTERNAL; + nMetronomeFrame = nFrame; + bBeat = true; + DPRINTF("Beat at tick %d, frame %u (%llu)\n", nTickTime, nFrame, nNow + nFrame); + if (++g_nBeat > nBeatsPerBar) { + // Bar g_nBeat = 1; - ++g_nBar; + bSync = true; + if (g_bTransportRolling) { + ++g_nBar; + } } - DPRINTF("Beat %u of %u\n", g_nBeat, g_nBeatsPerBar); } - // Send MIDI CLOCK ... - if (g_bSendMidiClock && g_bClientPlaying && g_nClock % (PPQN/PPQN_MIDI) == 0) { - jack_nframes_t nClockTime = g_qClockPos.front().first - nNow; - // Add a MIDI_CLOCK message to the schedule - g_mSchedule.insert(std::pair(nClockTime, new MIDI_MESSAGE({MIDI_CLOCK, 0, 0}))); - //if (bSync) - // g_mSchedule.insert(std::pair(nClockTime, new MIDI_MESSAGE({MIDI_CONTINUE, 0, 0}))); + + // *** THIS IS WHERE THE SEQUENCES ARE CLOCKED *** + //!@todo Optimise to reduce rate calling clock especially if we increase the clock rate from 24 to 96 or above. Maybe return the time until next check + uint8_t nPlayingSequences = g_seqMan.clock(nTickTime, &g_mSchedule, bSync); + + // Check for sequenced timebase changes (from patterns) + if (g_seqMan.isTempoChanged()) { + float tempo = g_seqMan.getTempo(); + setTempo(tempo); + } + if (g_seqMan.isTimeSigChanged()) { + uint8_t newBpb = g_seqMan.getTimeSig(true); + if (newBpb > 1) { + g_nBeatsPerBar = newBpb; + } } - if (g_nClockSource & TRANSPORT_CLOCK_INTERNAL) { - g_qClockPos.push(std::pair(g_qClockPos.back().first + g_nFramesPerClock, g_nFramesPerClock)); - //printf("NEXT CLOCK AT FRAME %u + %u = %u\n", g_qClockPos.back().first, g_nFramesPerClock, g_qClockPos.back().first + g_nFramesPerClock); + + if (bSync) { // Bar boundary actions + // Update time signature + if (bSync && nBeatsPerBar != g_nBeatsPerBar) { + nBeatsPerBar = g_nBeatsPerBar; + g_seqMan.setTimeSig(nBeatsPerBar); + } + // Stop transport + if (g_nPlayingSequences != nPlayingSequences) { + g_nPlayingSequences = nPlayingSequences; + if (!g_nPlayingSequences) { + DPRINTF("No sequences playing now: %u clock: %u beat: %u tick: %u\n", nNow, nTickTime, g_nBeat, g_nTick); + transportStop(TRANSPORT_CLIENT_ZYNSEQ); + } + } } - lastClock = g_qClockPos.front(); - g_qClockPos.pop(); + + // Update transport parameters + g_nBarStartTick = g_nTick; } - // g_nTick = g_nTicksPerBeat - nRemainingFrames / getFramesPerTick(g_dTempo); - - if (g_nPlayingSequences == 0 && (g_nClockSource & TRANSPORT_CLOCK_INTERNAL)) { - DPRINTF("Stopping transport because no sequences playing now: %u clock: %u beat: %u tick: %u\n", nNow, g_nClock, g_nBeat, g_nTick); - g_bMutex = false; - transportStop("zynseq"); - while (g_bMutex) - std::this_thread::sleep_for(std::chrono::microseconds(10)); - g_bMutex = true; - g_nMetronomePtr = -1; - // if(g_nClockSource & TRANSPORT_CLOCK_INTERNAL) - { - // Remove pending clocks - std::queue> qEmpty; - std::swap(g_qClockPos, qEmpty); + + // Send MIDI CLOCK... + if (nTickTime % (PPQN_INTERNAL / PPQN_MIDI) == 0) { + // Add a MIDI_CLOCK message to the schedule + g_mSchedule.insert(std::pair(nTickTime, new SEQ_EVENT({nTickTime, 0, MIDI_MESSAGE{MIDI_CLOCK, 0, 0}}))); + } + + + if (bBeat) { + if (g_nMetronomeMode == METRO_MODE_ON || + g_nMetronomeMode == METRO_MODE_TRANSPORT && (g_nTransportState == PLAYING || g_bTransportRolling) || + g_nMetronomeMode == METRO_MODE_INTRO && !(g_nPlayingSequences & 1)) { + // Start metronome + g_nMetronomePtr = 0; + g_pMetro = bSync ? &g_metro_peep : &g_metro_pip; + } else if (g_nMetronomeMode == METRO_MODE_NO_PEEP) { + g_nMetronomePtr = 0; + g_pMetro = &g_metro_pip; } } - if (g_bMetronome && g_nMetronomePtr >= 0) { - for (int n = nClockOffset; n < nFrames; ++n) { - if (g_nMetronomePtr < g_pMetro->size) { - pOutMetronome[n] = g_pMetro->data[g_nMetronomePtr++] * g_fMetronomeLevel; - } else { - g_nMetronomePtr = -1; - break; - } + ++nTickTime; + } + + // Play metronome sound + if (g_nMetronomePtr >= 0) { + for (int n = nMetronomeFrame; n < nFrames; ++n) { + if (g_nMetronomePtr < g_pMetro->size) { + pOutMetronome[n] = g_pMetro->data[g_nMetronomePtr++] * g_fMetronomeLevel; + } else { + g_nMetronomePtr = -1; + break; } } } // Process events scheduled to be sent to MIDI output - if (g_mSchedule.size()) { - auto it = g_mSchedule.begin(); - jack_nframes_t nTime = 0; - while (it != g_mSchedule.end()) { + size_t nTickIdx = 0; + auto it = g_mSchedule.begin(); + // Iterate the ticks in this period and events for each tick + while (it != g_mSchedule.end() && nTickIdx < vTicks.size()) { + // it->first is the scheduled tickTime of the event + if (it->first > nPeriodStartTick + nTickIdx) + ++nTickIdx; + else { + // Iterate events scheduled for this tick + size_t nSize = 1; bool bSkip = false; - if (it->first >= nNow + nFrames) - break; // Event scheduled beyond this buffer - if (it->first < nNow) { - nTime = 0; // This event is in the past so send as soon as possible - DPRINTF("Sending event from past (Scheduled:%u Now:%u Diff:%d samples)\n", it->first, nNow, nNow - it->first); - } else - nTime = it->first - nNow; // Schedule event at scheduled time sequence - if (nTime >= nFrames) { - g_bMutex = false; - return 0; // Must have bumped beyond end of this frame time so must wait until next frame - earlier events were processed and pointer nulled so - // will not trigger in next period - } - if (it->second) { - // Get a pointer to the next available bytes in the output buffer - size_t nSize = 1; - if (it->second->command < 0xF4) { - uint8_t nType = it->second->command; - if (nType < 0xF0) - nType &= 0xF0; - switch (nType) { - case MIDI_PROGRAM: - case MIDI_CHAN_PRESSURE: - case MIDI_TIMECODE: - case MIDI_SONG: - nSize = 2; - break; - case MIDI_CONTROL: - // Skip sustain events if recording and sustain is pressed - if (it->second->value1 == 64 && g_nSustainValue > 0) - bSkip = true; - nSize = 3; - default: - nSize = 3; - } - } - if (!bSkip) { - pBuffer = jack_midi_event_reserve(pOutputBuffer, nTime, nSize); - if (pBuffer == NULL) - break; // Exceeded buffer size (or other issue) - pBuffer[0] = it->second->command; - if (nSize > 1) - pBuffer[1] = it->second->value1; - if (nSize > 2) - pBuffer[2] = it->second->value2; - DPRINTF("Sending MIDI event %x,%x,%x at %u\n", pBuffer[0], pBuffer[1], pBuffer[2], nNow + nTime); + if (it->second->msg.command < 0xF4) { + uint8_t nType = it->second->msg.command; + if (nType < 0xF0) + nType &= 0xF0; + switch (nType) { + case MIDI_PROGRAM: + case MIDI_CHAN_PRESSURE: + case MIDI_TIMECODE: + case MIDI_SONG: + nSize = 2; + break; + case MIDI_CONTROL: + // Skip sustain events if recording and sustain is pressed + if (it->second->msg.value1 == 64 && g_nSustainValue > 0) + bSkip = true; + nSize = 3; + break; + case MIDI_NOTE_ON: + g_naHeldNote[it->second->msg.command & 0x0f][it->second->msg.value1] = it->second->msg.value2; + nSize = 3; + break; + case MIDI_NOTE_OFF: + g_naHeldNote[it->second->msg.command & 0x0f][it->second->msg.value1] = 0; + nSize = 3; + break; + default: + nSize = 3; } - delete it->second; - it->second = NULL; } - ++it; + jack_nframes_t nFrame = vTicks[nTickIdx]; + if (it->second->msg.command >= 0xF8 && it->second->msg.command <= 0xFC) { + unsigned char* pBuffer = jack_midi_event_reserve(pClockBuffer, nFrame, nSize); + if (pBuffer == NULL) + break; // Exceeded buffer size (or other issue) + pBuffer[0] = it->second->msg.command; + } else if (!bSkip) { + unsigned char* pBuffer = jack_midi_event_reserve(it->second->output == 0xfe ? pClippyBuffer : pOutputBuffer, nFrame, nSize); + if (pBuffer == NULL) + break; // Exceeded buffer size (or other issue) + pBuffer[0] = it->second->msg.command; + if (nSize > 1) + pBuffer[1] = it->second->msg.value1; + if (nSize > 2) + pBuffer[2] = it->second->msg.value2; + DPRINTF("Sending MIDI event %x,%x,%x at %llu\n", pBuffer[0], pBuffer[1], pBuffer[2], nNow + nFrame); + } + delete it->second; + it->second = NULL; + ++it; } g_mSchedule.erase(g_mSchedule.begin(), it); } @@ -742,8 +622,8 @@ int onJackSampleRateChange(jack_nframes_t nFrames, void* pArgs) { DPRINTF("zynseq: Jack sample rate: %u\n", nFrames); if (nFrames == 0) return 0; - g_nSampleRate = nFrames; - g_nFramesPerClock = getFramesPerClock(g_dTempo); + g_nSampleRate = nFrames; + updateClockTiming(); return 0; } @@ -755,10 +635,14 @@ int onJackXrun(void* pArgs) { void end() { DPRINTF("zynseq exit\n"); - std::this_thread::sleep_for(std::chrono::milliseconds(10)); + while (g_bMutex) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + g_bMutex = true; for (auto it : g_mSchedule) { delete it.second; } + g_bMutex = false; + freeState(); } // ** Library management functions ** @@ -768,8 +652,8 @@ __attribute__((constructor)) void zynseq(void) { fprintf(stderr, "Started libzyn void init(char* name) { //!@todo Invalid name triggers seg fault - g_metro_pip.data = metronome_pip; - g_metro_pip.size = sizeof(metronome_pip) / sizeof(float); + g_metro_pip.data = metronome_pip; + g_metro_pip.size = sizeof(metronome_pip) / sizeof(float); g_metro_peep.data = metronome_peep; g_metro_peep.size = sizeof(metronome_peep) / sizeof(float); @@ -789,17 +673,29 @@ void init(char* name) { return; } - // Create input port + // Create input ports if (!(g_pInputPort = jack_port_register(g_pJackClient, "input", JACK_DEFAULT_MIDI_TYPE, JackPortIsInput, 0))) { fprintf(stderr, "libzynseq cannot register input port\n"); return; } + if (!(g_pClockInputPort = jack_port_register(g_pJackClient, "clock_in", JACK_DEFAULT_MIDI_TYPE, JackPortIsInput, 0))) { + fprintf(stderr, "libzynseq cannot register clock input port\n"); + return; + } - // Create output port + // Create output ports if (!(g_pOutputPort = jack_port_register(g_pJackClient, "output", JACK_DEFAULT_MIDI_TYPE, JackPortIsOutput, 0))) { fprintf(stderr, "libzynseq cannot register output port\n"); return; } + if (!(g_pClockOutputPort = jack_port_register(g_pJackClient, "clock", JACK_DEFAULT_MIDI_TYPE, JackPortIsOutput, 0))) { + fprintf(stderr, "libzynseq cannot register MIDI clock output port\n"); + return; + } + if (!(g_pClippyOutputPort = jack_port_register(g_pJackClient, "clippy", JACK_DEFAULT_MIDI_TYPE, JackPortIsOutput, 0))) { + fprintf(stderr, "libzynseq cannot register clippy output port\n"); + return; + } // Create metronome output port if (!(g_pMetronomePort = jack_port_register(g_pJackClient, "metronome", JACK_DEFAULT_AUDIO_TYPE, JackPortIsOutput, 0))) { @@ -807,13 +703,14 @@ void init(char* name) { return; } - g_nSampleRate = jack_get_sample_rate(g_pJackClient); - g_nFramesPerClock = getFramesPerClock(g_dTempo); + g_nSampleRate = jack_get_sample_rate(g_pJackClient); + updateClockTiming(); // Register JACK callbacks jack_set_process_callback(g_pJackClient, onJackProcess, 0); jack_set_sample_rate_callback(g_pJackClient, onJackSampleRateChange, 0); - // jack_set_xrun_callback(g_pJackClient, onJackXrun, 0); //!@todo Remove xrun handler (just for debug) + jack_set_port_connect_callback(g_pJackClient, onJackConnect, 0); + //jack_set_xrun_callback(g_pJackClient, onJackXrun, 0); if (jack_activate(g_pJackClient)) { fprintf(stderr, "libzynseq cannot activate client\n"); @@ -823,86 +720,165 @@ void init(char* name) { // Register the cleanup function to be called when program exits atexit(end); - transportRequestTimebase(); - transportLocate(0); - g_pSequence = g_seqMan.getSequence(0, 0); + if (jack_set_timebase_callback(g_pJackClient, 0, onJackTimebase, NULL)) + fprintf(stderr, "ERROR: Failed to become timebase master\n"); + jack_transport_locate(g_pJackClient, 0); selectPattern(1); + setTempo(120.0); } bool isModified() { return g_bDirty; } -int fileWrite8(uint8_t value, FILE* pFile) { +// Write a single signed byte +int fileWrite8(int8_t value, FILE* pFile) { int nResult = fwrite(&value, 1, 1, pFile); return 1; } -int fileWrite32(uint32_t value, FILE* pFile) { - for (int i = 3; i >= 0; --i) - fileWrite8((value >> i * 8), pFile); - return 4; +// Write a single unsigned byte +int fileWrite8u(uint8_t value, FILE* pFile) { + int nResult = fwrite(&value, 1, 1, pFile); + return 1; +} + +// Write a 16-bit signed word as 2 bytes +int fileWrite16(int16_t value, FILE* pFile) { + for (int i = 1; i >= 0; --i) + fileWrite8u((value >> i * 8), pFile); + return 2; } -int fileWrite16(uint16_t value, FILE* pFile) { +// Write a 16-bit unsigned word as 2 bytes +int fileWrite16u(uint16_t value, FILE* pFile) { for (int i = 1; i >= 0; --i) - fileWrite8((value >> i * 8), pFile); + fileWrite8u((value >> i * 8), pFile); return 2; } -int fileWriteBCD(float v, FILE* f) { - uint16_t nUnits = uint16_t(v); - uint16_t nDecimal = uint16_t((v - nUnits) * 10000); - int nPos = fileWrite16(nDecimal, f); // fractional (BCD) - nPos += fileWrite16(nUnits, f); // integral (BCD) - return nPos; +// Write a 32-bit signed word as 4 bytes +int fileWrite32(int32_t value, FILE* pFile) { + for (int i = 3; i >= 0; --i) + fileWrite8u((value >> i * 8), pFile); + return 4; +} + +// Write a 32-bit unsigned word as 4 bytes +int fileWrite32u(uint32_t value, FILE* pFile) { + for (int i = 3; i >= 0; --i) + fileWrite8u((value >> i * 8), pFile); + return 4; +} + +int fileWrite32f(float value, FILE* pFile) { + uint8_t* p = (uint8_t*)&value; + for (int i = 3; i >= 0; --i) + fileWrite8u(*(p + i), pFile); + return 4; +} + +// Read a single signed byte +int8_t fileRead8(FILE* pFile) { + int8_t nResult = 0; + fread(&nResult, 1, 1, pFile); + return nResult; } -uint8_t fileRead8(FILE* pFile) { +// Read a single unsigned byte +uint8_t fileRead8u(FILE* pFile) { uint8_t nResult = 0; fread(&nResult, 1, 1, pFile); return nResult; } -uint16_t fileRead16(FILE* pFile) { +// Read a 2-byte signed word +int16_t fileRead16(FILE* pFile) { + int16_t nResult = 0; + for (int i = 1; i >= 0; --i) { + uint8_t nValue = fileRead8u(pFile); + nResult |= (nValue << (i * 8)); + } + return nResult; +} + +// Read a 2-byte unsigned word +uint16_t fileRead16u(FILE* pFile) { uint16_t nResult = 0; for (int i = 1; i >= 0; --i) { - uint8_t nValue; - fread(&nValue, 1, 1, pFile); - nResult |= nValue << (i * 8); + uint8_t nValue = fileRead8u(pFile); + nResult |= (nValue << (i * 8)); + } + return nResult; +} + +// Read a 4-byte signed word +int32_t fileRead32(FILE* pFile) { + int32_t nResult = 0; + for (int i = 3; i >= 0; --i) { + uint8_t nValue = fileRead8u(pFile); + nResult |= (nValue << (i * 8)); } return nResult; } -uint32_t fileRead32(FILE* pFile) { +// Read a 4-byte unsigned word +uint32_t fileRead32u(FILE* pFile) { uint32_t nResult = 0; for (int i = 3; i >= 0; --i) { - uint8_t nValue; - fread(&nValue, 1, 1, pFile); - nResult |= nValue << (i * 8); + uint8_t nValue = fileRead8u(pFile); + nResult |= (nValue << (i * 8)); } return nResult; } -float fileReadBCD(FILE* f) { return float(fileRead16(f)) / 10000 + fileRead16(f); } +// Read a 4-byte float +float fileRead32f(FILE* pFile) { + float fResult = 0.0; + uint8_t* p = (uint8_t*)&fResult; + for (int i = 3; i >= 0; --i) + *(p + i) = fileRead8u(pFile); + return fResult; +} + +// Read a BCD (Binary-Coded Decimal) value from a 4 byte word +float fileReadBCD(FILE* f) { + return float(fileRead16u(f)) / 10000 + fileRead16(f); +} +/* Check if there is sufficient data left in a block to process next stanza. If not, consume remaining bytes. */ bool checkBlock(FILE* pFile, uint32_t nActualSize, uint32_t nExpectedSize) { if (nActualSize < nExpectedSize) { for (size_t i = 0; i < nActualSize; ++i) - fileRead8(pFile); + fileRead8u(pFile); return true; } return false; } -bool load(const char* filename) { - g_pSequence = NULL; +void reset() { + g_nPhrase = 0; + g_nSequence = 0; g_seqMan.init(); + g_nScene = 0; + g_nBar = 1; + g_nBarStartTick = g_nTick; + g_nBeat = 1; + g_nDefaultBpb = DEFAULT_BPB; + g_nBeatsPerBar = DEFAULT_BPB; + // Create default phrases + for (uint8_t phrase = 0; phrase < 8; ++phrase) + insertPhrase(g_nScene, phrase); +} + +const char* convertToJson(const char* filename) { uint32_t nVersion = 0; FILE* pFile; pFile = fopen(filename, "r"); if (pFile == NULL) - return false; + return "{}"; char sHeader[4]; int bs; + json j; + // Iterate each block within IFF file while (fread(sHeader, 4, 1, pFile) == 1) { uint32_t nBlockSize = fileRead32(pFile); @@ -910,22 +886,22 @@ bool load(const char* filename) { if (nBlockSize != 16) { fclose(pFile); // printf("Error reading vers block from sequence file\n"); - return false; + return "{}"; } nVersion = fileRead32(pFile); - if (nVersion < 4 || nVersion > FILE_VERSION) { + if (nVersion < 4 || nVersion > 10) { fclose(pFile); - DPRINTF("Unsupported sequence file version %d. Not loading file.\n", nVersion); - return false; + printf("Unsupported sequence file version %d. Not loading file.\n", nVersion); + return "{}"; } - g_dTempo = fileRead16(pFile); //!@todo save and load tempo as fraction of BPM - g_nBeatsPerBar = fileRead16(pFile); - g_seqMan.setTriggerChannel(fileRead8(pFile)); - g_seqMan.setTriggerDevice(fileRead8(pFile)); - fileRead8(pFile); //!@todo Set JACK output + j["tempo"] = fileRead16(pFile); //!@todo save and load tempo as fraction of BPM + j["bpb"] = fileRead16(pFile); + fileRead8u(pFile); // No longer use trigger channel + fileRead8u(pFile); // No longer use trigger input + fileRead8u(pFile); // No longer use trigger output fileRead8(pFile); // padding - g_nVerticalZoom = fileRead16(pFile); - g_nHorizontalZoom = fileRead16(pFile); + fileRead16u(pFile); // No longer use vertical zoom + fileRead16u(pFile); // No longer use horizontal zoom // printf("Version:%u Tempo:%0.2lf Beats per bar:%u Zoom V:%u H:%u\n", nVersion, g_dTempo, g_nBeatsPerBar, g_nVerticalZoom, g_nHorizontalZoom); } else if (memcmp(sHeader, "patn", 4) == 0) { if (nVersion > 8) { @@ -938,29 +914,29 @@ bool load(const char* filename) { if (checkBlock(pFile, nBlockSize, 12)) continue; } + json patj; uint32_t nPattern = fileRead32(pFile); - Pattern* pPattern = g_seqMan.getPattern(nPattern); - pPattern->clear(); - pPattern->resetSnapshots(); - pPattern->setBeatsInPattern(fileRead32(pFile)); - pPattern->setStepsPerBeat(fileRead16(pFile)); - pPattern->setScale(fileRead8(pFile)); - pPattern->setTonic(fileRead8(pFile)); + uint32_t beats = fileRead32(pFile); + uint16_t spb = fileRead16(pFile); + patj["steps"] = beats * spb; + patj["beats"] = beats; + patj["scale"] = fileRead8u(pFile); + patj["tonic"] = fileRead8u(pFile); if (nVersion > 4) { - pPattern->setRefNote(fileRead8(pFile)); + patj["refNote"] = fileRead8u(pFile); //!@todo What is this? nBlockSize -= 1; } if (nVersion > 8) { - pPattern->setQuantizeNotes(fileRead8(pFile)); - pPattern->setSwingDiv(fileRead8(pFile)); - pPattern->setSwingAmount(fileReadBCD(pFile)); - pPattern->setHumanTime(fileReadBCD(pFile)); - pPattern->setHumanVelo(fileReadBCD(pFile)); - pPattern->setPlayChance(fileReadBCD(pFile)); + patj["quantize"] = fileRead8u(pFile); + patj["swingDiv"] = fileRead8u(pFile); + patj["swing"] = fileReadBCD(pFile); + patj["humanTime"] = fileReadBCD(pFile); + patj["humanVel"] = fileReadBCD(pFile); + patj["chance"] = int(fileReadBCD(pFile) * 100); nBlockSize -= 18; } if (nVersion > 4) { - fileRead8(pFile); + fileRead8(pFile); // padding nBlockSize -= 1; } nBlockSize -= 12; @@ -977,61 +953,111 @@ bool load(const char* filename) { if (checkBlock(pFile, nBlockSize, 14)) break; } - uint32_t nStep = fileRead32(pFile); + json jEvent; + jEvent.push_back(fileRead32(pFile)); // step float fDuration, fOffset; if (nVersion > 8) { - fOffset = fileReadBCD(pFile); - fDuration = fileReadBCD(pFile); + jEvent.push_back(fileReadBCD(pFile)); // offset + jEvent.push_back(fileReadBCD(pFile)); // duration nBlockSize -= 4; } else { - fOffset = 0; - fDuration = float(fileRead16(pFile)) / 100 + fileRead16(pFile); // fractional + integral (BCD) + jEvent.push_back(0); + jEvent.push_back(float(fileRead16(pFile)) / 100 + fileRead16(pFile)); // fractional + integral (BCD) } - uint8_t nCommand = fileRead8(pFile); - uint8_t nValue1start = fileRead8(pFile); - uint8_t nValue2start = fileRead8(pFile); - uint8_t nValue1end = fileRead8(pFile); - uint8_t nValue2end = fileRead8(pFile); - StepEvent* pEvent = pPattern->addEvent(nStep, nCommand, nValue1start, nValue2start, fDuration, fOffset); - pEvent->setValue1end(nValue1end); - pEvent->setValue2end(nValue2end); + jEvent.push_back(fileRead8u(pFile)); // command + jEvent.push_back(fileRead8u(pFile)); // value 1 start + jEvent.push_back(fileRead8u(pFile)); // value 2 start + jEvent.push_back(fileRead8u(pFile)); // value 1 end + jEvent.push_back(fileRead8u(pFile)); // value 2 end + if (nVersion > 7) { - uint8_t nStutterCount = fileRead8(pFile); - uint8_t nStutterDur = fileRead8(pFile); - pEvent->setStutterCount(nStutterCount); - pEvent->setStutterDur(nStutterDur); + // Read stutter legacy values + uint8_t stut_cnt = fileRead8u(pFile); // Legacy stutter count + uint8_t stut_dur = fileRead8u(pFile); // Legacy stutter duration + if (stut_cnt > 0) { // Stutter speed calculated from legacy values + uint16_t legacy_clocks_step = 24 * patj.value("beats", 4) / patj.value("steps", 16); // 6 by default (96/16) => 4 steps/beat + jEvent.push_back(legacy_clocks_step / stut_cnt); + } else { + jEvent.push_back(0); + } + jEvent.push_back(0); // Stutter velocity FX nBlockSize -= 2; + } else { + jEvent.push_back(0); + jEvent.push_back(0); } - if (nVersion > 8) { - uint8_t nPlayChance = fileRead8(pFile); - pEvent->setPlayChance(nPlayChance); + jEvent.push_back(0); // Stutter speed ramp + + if (nVersion > 8) { // Play chance + jEvent.push_back(int(fileReadBCD(pFile) * 100)); nBlockSize -= 1; + } else { + jEvent.push_back(100); } - fileRead8(pFile); // Padding + jEvent.push_back(1); // Play frequency + jEvent.push_back(100); // Stutter chance + jEvent.push_back(1); // Stutter frequency + fileRead8(pFile); // Padding nBlockSize -= 14; // printf(" Step:%u Duration:%u Command:%02X, Value1:%u..%u, Value2:%u..%u\n", nTime, nDuration, nCommand, nValue1start, nValue2end, // nValue2start, nValue2end); + patj["events"].push_back(jEvent); } - pPattern->resetSnapshots(); + j["patns"][std::to_string(nPattern)] = patj; } else if (memcmp(sHeader, "bank", 4) == 0) { - // Load banks + // Load scenes if (checkBlock(pFile, nBlockSize, 6)) continue; - uint8_t nBank = fileRead8(pFile); + uint8_t nScene = fileRead8u(pFile) - 1; // Legacy did not save scene (bank) 0 fileRead8(pFile); // Padding uint32_t nSequences = fileRead32(pFile); nBlockSize -= 6; - // printf("Bank %u with %u sequences\n", nBank, nSequences); + json jScene; + uint8_t nextPhrase[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; // Index of next phrase to add MIDI channel based sequence. + bool bAddScene = false; for (uint32_t nSequence = 0; nSequence < nSequences; ++nSequence) { if (nVersion > 5 && checkBlock(pFile, nBlockSize, 24)) continue; else if (checkBlock(pFile, nBlockSize, 8)) continue; - Sequence* pSequence = g_seqMan.getSequence(nBank, nSequence); - pSequence->setPlayMode(fileRead8(pFile)); - uint8_t nGroup = fileRead8(pFile); - pSequence->setGroup(nGroup); - g_seqMan.setTriggerNote(nBank, nSequence, fileRead8(pFile)); + uint8_t nMidiChan; // Used to define which phrase to add sequence to + json jSeq; + bool bAddSeq = false; + switch (fileRead8u(pFile)) { + case 0: + // DISABLED + jSeq["repeat"] = 0; + break; + case 1: + // ONESHOT + jSeq["mode"] = MODE_END_IMMEDIATE; + break; + case 2: + // LOOP + jSeq["followAction"] = FOLLOW_ACTION_RELATIVE; + jSeq["followParam"] = 0; + break; + case 3: + // ONESHOTALL + break; + case 4: + // LOOPALL + jSeq["followAction"] = FOLLOW_ACTION_RELATIVE; + jSeq["followParam"] = 0; + break; + case 5: + // ONESHOTSYNC + jSeq["mode"] = MODE_END_SYNC; + break; + case 6: + // LOOPSYNC + jSeq["followAction"] = FOLLOW_ACTION_RELATIVE; + jSeq["followParam"] = 0; + break; + } + uint8_t nGroup = fileRead8u(pFile); + jSeq["group"] = nGroup; + fileRead8u(pFile); // No longer use trigger note fileRead8(pFile); // Padding char sName[17]; memset(sName, '\0', 17); @@ -1039,16 +1065,15 @@ bool load(const char* filename) { if (checkBlock(pFile, nBlockSize, 24)) continue; for (size_t nIndex = 0; nIndex < 16; ++nIndex) - sName[nIndex] = fileRead8(pFile); + sName[nIndex] = fileRead8u(pFile); sName[16] = '\0'; nBlockSize -= 16; } else { sprintf(sName, "%d", nSequence + 1); } - pSequence->setName(std::string(sName)); + jSeq["name"] = std::string(sName); uint32_t nTracks = fileRead32(pFile); nBlockSize -= 8; - // printf(" Mode:%u Group:%u Tracks:%u\n", pSequence->getPlayMode(), pSequence->getGroup(), nTracks); if (nVersion > 9) bs = 8; else @@ -1056,16 +1081,13 @@ bool load(const char* filename) { for (uint32_t nTrack = 0; nTrack < nTracks; ++nTrack) { if (checkBlock(pFile, nBlockSize, bs)) break; - if (pSequence->getTracks() <= nTrack) - pSequence->addTrack(nTrack); - Track* pTrack = pSequence->getTrack(nTrack); - if (nVersion > 9) { - pTrack->setType(fileRead8(pFile)); - pTrack->setChainID(fileRead8(pFile)); - } - pTrack->setChannel(fileRead8(pFile)); - pTrack->setOutput(fileRead8(pFile)); - pTrack->setMap(fileRead8(pFile)); + json trackj; + if (nVersion > 9) + fileRead16(pFile); // Type & chain id not used + nMidiChan = fileRead8u(pFile); + trackj["chan"] = nMidiChan; + trackj["output"] = fileRead8u(pFile); + trackj["map"] = fileRead8u(pFile); fileRead8(pFile); // Padding uint16_t nPatterns = fileRead16(pFile); nBlockSize -= bs; @@ -1073,12 +1095,15 @@ bool load(const char* filename) { for (uint16_t nPattern = 0; nPattern < nPatterns; ++nPattern) { if (checkBlock(pFile, nBlockSize, 8)) break; - uint32_t nTime = fileRead32(pFile); - uint32_t nPatternId = fileRead32(pFile); - g_seqMan.addPattern(nBank, nSequence, nTrack, nTime, nPatternId, true); + uint32_t nTime = fileRead32(pFile); + uint32_t nId = fileRead32(pFile); + if (j["patns"].contains(std::to_string(nId))) { + trackj["patns"][std::to_string(nTime)] = nId; + bAddSeq = true; + } nBlockSize -= 8; - // printf(" Pattern:%u at time:%u\n", nPatternId, nTime); } + jSeq["tracks"].push_back(trackj); } if (checkBlock(pFile, nBlockSize, 4)) break; @@ -1087,50 +1112,421 @@ bool load(const char* filename) { for (uint32_t nEvent = 0; nEvent < nTimebaseEvents; ++nEvent) { if (checkBlock(pFile, nBlockSize, 8)) break; - pSequence->getTimebase()->addTimebaseEvent(fileRead16(pFile), fileRead16(pFile), fileRead16(pFile), fileRead16(pFile)); + json tbjEvent; + tbjEvent["bar"] = fileRead16(pFile); + tbjEvent["tick"] = fileRead16(pFile); + tbjEvent["type"] = fileRead16(pFile); + tbjEvent["value"] = fileRead16(pFile); nBlockSize -= 8; + jSeq["timebase"].push_back(tbjEvent); // printf(" Timebase event:%u at time %u\n", pSequence->) } - pSequence->updateLength(); + if (nTracks == 1) { + // Single track sequence so add to phrase defined by MIDI channel + uint8_t nPhrase = nextPhrase[nMidiChan]; + if (jScene["phrases"].size() <= nPhrase) { + // Create phrase + json jPhrase; + jPhrase["name"] = std::string(1, 'A' + nPhrase); + jPhrase["mode"] = 4; // Phrase play mode + jScene["phrases"].push_back(jPhrase); + } + if (bAddSeq) { + // Don't add empty sequences + while (jScene["phrases"][nPhrase]["sequences"].size() < nMidiChan) + jScene["phrases"][nPhrase]["sequences"].emplace_back(); + jScene["phrases"][nPhrase]["sequences"][nMidiChan] = jSeq; + nextPhrase[nMidiChan] += 1; + bAddScene = true; + } + } + else { + //!@todo Handle multi-track sequences + } } + if (bAddScene) // Don't add empty scenes + j["scenes"].push_back(jScene); } } fclose(pFile); - // printf("Ver: %d Loaded %lu patterns, %lu sequences, %lu banks from file %s\n", nVersion, m_mPatterns.size(), m_mSequences.size(), m_mBanks.size(), - // filename); - g_bDirty = false; - g_pSequence = g_seqMan.getSequence(0, 0); - selectPattern(1); + std::string json_str = j.dump(); + free(g_pState); + g_pState = (char*)malloc(json_str.size() + 1); + std::strcpy(g_pState, json_str.c_str()); + return g_pState; +} + +void setPattern(uint32_t id, const char* patn_state) { + json jPattern = json::parse(patn_state); + Pattern* pPattern = g_seqMan.getPattern(id); + pPattern->clear(); + pPattern->setBeatsInPattern(jPattern.value("beats", 4)); + pPattern->setStepsPerBeat(jPattern.value("steps", 16) / pPattern->getBeatsInPattern()); + pPattern->setScale(jPattern.value("scale", 0)); + pPattern->setTonic(jPattern.value("tonic", 0)); + pPattern->setRefNote(jPattern.value("refNote", 60)); + pPattern->setZoom(jPattern.value("zoom", 0)); + if (jPattern.contains("ccnum")) { + for (uint8_t ccnum = 0; ccnum < 128; ++ccnum) + pPattern->setInterpolateCC(ccnum, jPattern["ccnum"][ccnum]); + } + pPattern->setQuantizeNotes(jPattern.value("quantize", 0)); + pPattern->setSwingDiv(jPattern.value("swingDiv", 1)); + pPattern->setSwingAmount(jPattern.value("swing", 0.0)); + pPattern->setHumanTime(jPattern.value("humanTime", 0.0)); + pPattern->setHumanVelo(jPattern.value("humanVel", 0.0)); + pPattern->setPlayChance(float(jPattern.value("chance", 100)) / 100); + for (auto& jEvent: jPattern["events"]) { + uint32_t nStep = jEvent[0]; + float fDuration = jEvent[1]; + float fOffset = jEvent[2]; + uint8_t nCommand = jEvent[3]; + uint8_t nValue1start = jEvent[4]; + uint8_t nValue2start = jEvent[6]; + StepEvent* pEvent = pPattern->addEvent(nStep, nCommand, nValue1start, nValue2start, fDuration, fOffset); + pEvent->setValue1end(jEvent[5]); + pEvent->setValue2end(jEvent[7]); + pEvent->setStutterSpeed(jEvent[8]); + pEvent->setStutterVelfx(jEvent[9]); + pEvent->setStutterRamp(jEvent[10]); + pEvent->setPlayChance(float(jEvent[11]) / 100); + pEvent->setPlayFreq(jEvent[12]); + pEvent->setStutterChance(float(jEvent[13]) / 100); + pEvent->setStutterFreq(jEvent[14]); + } +} + +bool setState(const char* state) { + try { + json j = json::parse(state); + g_nPhrase = 0; + g_nSequence = 0; + uint8_t nLowestScene = 255; + + g_seqMan.init(); + + setTempo(j.value("tempo", g_dTempo)); //!@todo Do we want to reset tempo to default or use previous if not in state? + setDefaultBpb(j.value("bpb", DEFAULT_BPB)); + //fprintf(stderr, "Default Timesig = %d\n", j.value("bpb", DEFAULT_BPB)); + + if (j.contains("patns")) { + for (auto& [key, jPattern]: j["patns"].items()) { + uint32_t id = std::stoi(key); + //!@todo We could reuse setPattern but that means encoding and re-decoding the json + Pattern* pPattern = g_seqMan.getPattern(id); + pPattern->clear(); + pPattern->setBeatsInPattern(jPattern.value("beats", 4)); + pPattern->setStepsPerBeat(jPattern.value("steps", 16) / pPattern->getBeatsInPattern()); + pPattern->setScale(jPattern.value("scale", 0)); + pPattern->setTonic(jPattern.value("tonic", 0)); + pPattern->setRefNote(jPattern.value("refNote", 60)); + pPattern->setZoom(jPattern.value("zoom", 0)); + if (jPattern.contains("ccnum")) { + for (uint8_t ccnum = 0; ccnum < 128; ++ccnum) + pPattern->setInterpolateCC(ccnum, jPattern["ccnum"][ccnum]); + } + pPattern->setQuantizeNotes(jPattern.value("quantize", 0)); + pPattern->setSwingDiv(jPattern.value("swingDiv", 1)); + pPattern->setSwingAmount(jPattern.value("swing", 0.0)); + pPattern->setHumanTime(jPattern.value("humanTime", 0.0)); + pPattern->setHumanVelo(jPattern.value("humanVel", 0.0)); + pPattern->setPlayChance(float(jPattern.value("chance", 100)) / 100); + for (auto& jEvent: jPattern["events"]) { + uint32_t nStep = jEvent[0]; + float fOffset = jEvent[1]; + float fDuration = jEvent[2]; + uint8_t nCommand = jEvent[3]; + uint8_t nValue1start = jEvent[4]; + uint8_t nValue2start = jEvent[6]; + StepEvent* pEvent = pPattern->addEvent(nStep, nCommand, nValue1start, nValue2start, fDuration, fOffset); + pEvent->setValue1end(jEvent[5]); + pEvent->setValue2end(jEvent[7]); + pEvent->setStutterSpeed(jEvent[8]); + pEvent->setStutterVelfx(jEvent[9]); + // Legacy format + if (jEvent.size() == 11) { + pEvent->setPlayChance(float(jEvent[10]) / 100); + } + // Extended parameters: stutter speed-ramp, play freq, stutter chance, stutter freq + else { + pEvent->setStutterRamp(jEvent[10]); + pEvent->setPlayChance(float(jEvent[11]) / 100); + pEvent->setPlayFreq(jEvent[12]); + pEvent->setStutterChance(float(jEvent[13]) / 100); + pEvent->setStutterFreq(jEvent[14]); + } + } + } + } + if (j.contains("scenes")) { + for (uint32_t nScene = 0; nScene > vFollowActions; + for (auto& jPhrase: jScene["phrases"]) { + Sequence* pPhrase = g_seqMan.insertPhrase(nScene, -1); + if (!pPhrase) + continue; + + if (jPhrase.contains("name")) + pPhrase->setName(jPhrase["name"]); + if (jPhrase.contains("mode")) + pPhrase->setPlayMode(jPhrase["mode"]); + + // Set phrase time signature, fixing if needed + phrase_bpb = jPhrase.value("bpb", DEFAULT_BPB); + if (phrase_bpb <= 0) + phrase_bpb = DEFAULT_BPB; + pPhrase->setTimeSig(phrase_bpb); + //fprintf(stderr, "Phrase %d Timesig = %d\n", nPhrase, phrase_bpb); + + pPhrase->setTempo(jPhrase.value("tempo", 0)); + pPhrase->setRepeat(jPhrase.value("repeat", 1)); + + // Store the follow configuration to apply after all sequences have been created + std::array followAction; + followAction[0] = nPhrase; + followAction[1] = PHRASE_CHANNEL; + followAction[2] = jPhrase.value("followAction", FOLLOW_ACTION_NONE); + followAction[3] = jPhrase.value("followParam", 0); + vFollowActions.push_back(followAction); + + uint8_t nSeq = 0; + for (auto& jSeq: jPhrase["sequences"]) { + uint32_t nTracks = jSeq["tracks"].size(); + if (nTracks == 1) { + // Single track sequences are mapped by their first midi channel + //nSeq = jSeq["tracks"][0].value("chan", 0); + } else { + //!@todo Handle multtrack sequences + //fprintf(stderr, "Ignoring multitrack sequence\n"); + //continue; + } + Sequence* pSequence = g_seqMan.getSequence(nScene, nPhrase, nSeq); + if (!pSequence) { + fprintf(stderr, "getSequence(%u, %u, %u) failed\n", nScene, nPhrase, nSeq); + continue; + } + pSequence->setPlayMode(jSeq.value("mode", 1)); + pSequence->setGroup(jSeq.value("group", 0)); //!@todo Set default group to MIDI channel + pSequence->setName(jSeq.value("name", "")); + pSequence->setRepeat(jSeq.value("repeat", 1)); + + // Store the follow configuration to apply after all sequences have been created + std::array followAction; + followAction[0] = nPhrase; + followAction[1] = nSeq; + followAction[2] = jSeq.value("followAction", FOLLOW_ACTION_NONE); + followAction[3] = jSeq.value("followParam", 0); + vFollowActions.push_back(followAction); + uint32_t nTrack = 0; + for (auto& jTrack: jSeq["tracks"]) { + if (pSequence->getTracks() <= nTrack) + pSequence->addTrack(nTrack); + Track* pTrack = pSequence->getTrack(nTrack); + pTrack->setChannel(jTrack.value("chan", 0)); + pTrack->setOutput(jTrack.value("output", 0)); + pTrack->setMap(jTrack.value("map", 0)); + for (auto& [sTime, jPatn]: jTrack["patns"].items()){ + uint32_t nTime = std::stoi(sTime); + uint32_t nPatn = jPatn.get(); + g_seqMan.addPattern(pSequence, nTrack, nTime, nPatn, true); + } + ++nTrack; + } + if (jSeq.contains("timebase")) { + for (auto& jTbEvt: jSeq["timebase"]) { + pSequence->getTimebase()->addTimebaseEvent(jTbEvt["bar"], jTbEvt["tick"], jTbEvt["type"], jTbEvt["value"]); + } + } + ++nSeq; + } + // Set Phrase BPB after adding the patterns + pPhrase->setTimeSig(phrase_bpb); + ++nPhrase; + } + // Set follow actions late, after creating all sequence objects + for (auto& followAction : vFollowActions) { + Sequence* pSeq = g_seqMan.getSequence(nScene, followAction[0], followAction[1]); + g_seqMan.setFollowAction(nScene, pSeq, followAction[2], followAction[3]); + } + } + } + // Reset dirty flag + g_bDirty = false; + // Setup scene + if (nLowestScene == 255) + nLowestScene = 0; + setScene(j.value("scene", nLowestScene)); + } catch (const nlohmann::json::exception& e) { + fprintf(stderr, "Failed to set zynseq state due to json handling exception: %s\n", e.what()); + reset(); + return false; + } return true; } -bool load_pattern(uint32_t nPattern, const char* filename) { +const char* getState() { + uint8_t nScene = getScene(); + json jState; + jState["tempo"] = g_dTempo; + jState["bpb"] = g_nDefaultBpb; + jState["scene"] = nScene; + // Iterate through patterns + uint32_t nPattern = 0; + while ((nPattern = g_seqMan.getNextPattern(nPattern)) != -1) { + Pattern* pPattern = g_seqMan.getPattern(nPattern); + // Only save patterns with content + if (pPattern->getEventAt(0)) { + json jPatn; + uint32_t nBeats = pPattern->getBeatsInPattern(); + jPatn["beats"] = nBeats; + jPatn["steps"] = nBeats * pPattern->getStepsPerBeat(); + jPatn["scale"] = pPattern->getScale(); + jPatn["tonic"] = pPattern->getTonic(); + jPatn["refNote"] = pPattern->getRefNote(); + jPatn["zoom"] = pPattern->getZoom(); + jPatn["quantize"] = pPattern->getQuantizeNotes(); + jPatn["swingDiv"] = pPattern->getSwingDiv(); + jPatn["swing"] = pPattern->getSwingAmount(); + jPatn["humanTime"] = pPattern->getHumanTime(); + jPatn["humanVel"] = pPattern->getHumanVelo(); + jPatn["chance"] = int(pPattern->getPlayChance() * 100); + uint32_t nEvent = 0; + while (StepEvent* pEvent = pPattern->getEventAt(nEvent++)) { + json jEvt; + // Event Position (step) + jEvt.push_back(pEvent->getPosition()); + jEvt.push_back(pEvent->getOffset()); + jEvt.push_back(pEvent->getDuration()); + jEvt.push_back(pEvent->getCommand()); + jEvt.push_back(pEvent->getValue1start()); + jEvt.push_back(pEvent->getValue1end()); + jEvt.push_back(pEvent->getValue2start()); + jEvt.push_back(pEvent->getValue2end()); + jEvt.push_back(pEvent->getStutterSpeed()); + jEvt.push_back(pEvent->getStutterVelfx()); + jEvt.push_back(pEvent->getStutterRamp()); + jEvt.push_back(int(pEvent->getPlayChance() * 100)); + jEvt.push_back(pEvent->getPlayFreq()); + jEvt.push_back(int(pEvent->getStutterChance() * 100)); + jEvt.push_back(pEvent->getStutterFreq()); + jPatn["events"].push_back(jEvt); + } + jState["patns"][std::to_string(nPattern)] = jPatn; + } + } + + // Iterate through scenes + for (uint32_t nScene = 0; nScene < g_seqMan.getNumScenes(); ++nScene) { + json jScene; + uint32_t nPhrase = 0; + while (true) { + Sequence* pPhrase = g_seqMan.getSequence(nScene, nPhrase, PHRASE_CHANNEL); + if (!pPhrase) // Reached end of phrases + break; + json jPhrase; + //!@todo Optimise - do not save default values + jPhrase["name"] = pPhrase->getName().c_str(); + jPhrase["mode"] = pPhrase->getPlayMode(); + jPhrase["bpb"] = pPhrase->getTimeSig(); + jPhrase["tempo"] = pPhrase->getTempo(); + jPhrase["repeat"] = pPhrase->getRepeat(); + jPhrase["followAction"] = pPhrase->getFollowAction(); + jPhrase["followParam"] = pPhrase->getFollowParam(); + jPhrase["state"] = pPhrase->getPlayState(); + for (const auto& pSequence : pPhrase->m_aChildSequences) { + json jSeq; + if (pSequence) { + jSeq["mode"] = pSequence->getPlayMode(); + jSeq["group"] = pSequence->getGroup(); + jSeq["name"] = pSequence->getName().c_str(); + jSeq["mode"] = pSequence->getPlayMode(); + jSeq["repeat"] = pSequence->getRepeat(); + jSeq["followAction"] = pSequence->getFollowAction(); + jSeq["followParam"] = pSequence->getFollowParam(); + jSeq["state"] = pSequence->getPlayState(); + for (size_t nTrack = 0; nTrack < pSequence->getTracks(); ++nTrack) { + Track* pTrack = pSequence->getTrack(nTrack); + if (pTrack) { + json jTrack; + jTrack["chan"] = pTrack->getChannel(); + jTrack["output"] = pTrack->getOutput(); + jTrack["map"] = pTrack->getMap(); + for (uint16_t nPattern = 0; nPattern < pTrack->getPatterns(); ++nPattern) { + std::string sPos = std::to_string(pTrack->getPatternPositionByIndex(nPattern)); + Pattern* pPattern = pTrack->getPatternByIndex(nPattern); + uint32_t nPatternId = g_seqMan.getPatternIndex(pPattern); + jTrack["patns"][sPos] = nPatternId; + } + jSeq["tracks"].push_back(jTrack); + } + } + Timebase* pTimebase = pSequence->getTimebase(); + if (pTimebase) { + json jTimebase; + for (uint32_t nIndex = 0; nIndex < pTimebase->getEventQuant(); ++nIndex) { + TimebaseEvent* pEvent = pTimebase->getEvent(nIndex); + jTimebase["bar"] = pEvent->bar; + jTimebase["tick"] = pEvent->clock; + jTimebase["type"] = pEvent->type; + jTimebase["value"] = pEvent->value; + jSeq["timebase"].push_back(jTimebase); + } + } + } + jPhrase["sequences"].push_back(jSeq); + } + jScene["phrases"].push_back(jPhrase); + ++nPhrase; + } + jState["scenes"].push_back(jScene); + } + + std::string json_str = jState.dump(); + free(g_pState); + g_pState = (char*)malloc(json_str.size() + 1); + std::strcpy(g_pState, json_str.c_str()); + return g_pState; +} + +void freeState() { + free (g_pState); + g_pState = nullptr; +} + +const char* convertPattern(uint32_t nPattern, const char* filename) { + // Legacy binary format uint32_t nVersion = 0; FILE* pFile; pFile = fopen(filename, "r"); if (pFile == NULL) - return false; + return nullptr; + json jPattern; char sHeader[4]; // Iterate each block within IFF file while (fread(sHeader, 4, 1, pFile) == 1) { - uint32_t nBlockSize = fileRead32(pFile); + uint32_t nBlockSize = fileRead32u(pFile); if (memcmp(sHeader, "vers", 4) == 0) { if (nBlockSize != 10) { fclose(pFile); printf("Error reading vers block from pattern file\n"); - return false; + return nullptr; } - nVersion = fileRead32(pFile); + nVersion = fileRead32u(pFile); if (nVersion < 4 || nVersion > FILE_VERSION) { fclose(pFile); DPRINTF("Unsupported pattern file version %d. Not loading file.\n", nVersion); - return false; + return nullptr; } // Loaded from file but not used! // g_nBeatsPerBar, g_nVerticalZoom, g_nHorizontalZoom - fileRead16(pFile); - fileRead16(pFile); - fileRead16(pFile); + fileRead16u(pFile); + fileRead16u(pFile); + fileRead16u(pFile); // printf("Version:%u Beats per bar:%u Zoom V:%u H:%u\n", nVersion, g_nBeatsPerBar, g_nVerticalZoom, g_nHorizontalZoom); } else if (memcmp(sHeader, "patn", 4) == 0) { if (nVersion > 8) { @@ -1143,31 +1539,29 @@ bool load_pattern(uint32_t nPattern, const char* filename) { if (checkBlock(pFile, nBlockSize, 8)) continue; } - Pattern* pPattern = g_seqMan.getPattern(nPattern); - pPattern->clear(); - pPattern->setBeatsInPattern(fileRead32(pFile)); - pPattern->setStepsPerBeat(fileRead16(pFile)); - pPattern->setScale(fileRead8(pFile)); - pPattern->setTonic(fileRead8(pFile)); + jPattern["beats"] = fileRead32u(pFile); + jPattern["steps"] = fileRead16u(pFile); + jPattern["scale"] = fileRead8u(pFile); + jPattern["tonic"] = fileRead8u(pFile); if (nVersion > 4) { - pPattern->setRefNote(fileRead8(pFile)); + jPattern["refNote"] = fileRead8u(pFile); nBlockSize -= 1; } if (nVersion > 8) { - pPattern->setQuantizeNotes(fileRead8(pFile)); - pPattern->setSwingDiv(fileRead8(pFile)); - pPattern->setSwingAmount(fileReadBCD(pFile)); - pPattern->setHumanTime(fileReadBCD(pFile)); - pPattern->setHumanVelo(fileReadBCD(pFile)); - pPattern->setPlayChance(fileReadBCD(pFile)); + jPattern["quantize"] = fileRead8u(pFile); + jPattern["swingDiv"] = fileRead8u(pFile); + jPattern["swing"] = fileReadBCD(pFile); + jPattern["humanTime"] = fileReadBCD(pFile); + jPattern["humanVel"] = fileReadBCD(pFile); + jPattern["chance"] = int(100 * fileReadBCD(pFile)); nBlockSize -= 18; } if (nVersion > 4) { - fileRead8(pFile); + fileRead8u(pFile); nBlockSize -= 1; } nBlockSize -= 8; - // printf("Pattern:%u Beats:%u StepsPerBeat:%u Scale:%u Tonic:%u\n", nPattern, pPattern->getBeatsInPattern(), pPattern->getStepsPerBeat(), + // printf("Pattern:%u Beats:%u StepsPerBeat:%u Scale:%u Tonic:%u\n", nPattern, pPattern->getBeatsInPattern(nPattern), pPattern->getStepsPerBeat(), // pPattern->getScale(), pPattern->getTonic()); while (nBlockSize) { if (nVersion > 8) { @@ -1180,274 +1574,108 @@ bool load_pattern(uint32_t nPattern, const char* filename) { if (checkBlock(pFile, nBlockSize, 14)) break; } - uint32_t nStep = fileRead32(pFile); - float fDuration, fOffset; + json jEvent; + jEvent.push_back(fileRead32(pFile)); // step if (nVersion > 8) { - fOffset = fileReadBCD(pFile); - fDuration = fileReadBCD(pFile); + jEvent.push_back(fileReadBCD(pFile)); // offset + jEvent.push_back(fileReadBCD(pFile)); // duration nBlockSize -= 4; } else { - fOffset = 0; - fDuration = float(fileRead16(pFile)) / 100 + fileRead16(pFile); // fractional + integral (BCD) + jEvent.push_back(0); + jEvent.push_back(float(fileRead16(pFile)) / 100 + fileRead16(pFile)); // fractional + integral (BCD) } - uint8_t nCommand = fileRead8(pFile); - uint8_t nValue1start = fileRead8(pFile); - uint8_t nValue2start = fileRead8(pFile); - uint8_t nValue1end = fileRead8(pFile); - uint8_t nValue2end = fileRead8(pFile); - StepEvent* pEvent = pPattern->addEvent(nStep, nCommand, nValue1start, nValue2start, fDuration, fOffset); - pEvent->setValue1end(nValue1end); - pEvent->setValue2end(nValue2end); + jEvent.push_back(fileRead8u(pFile)); // command + jEvent.push_back(fileRead8u(pFile)); // value 1 start + jEvent.push_back(fileRead8u(pFile)); // value 2 start + jEvent.push_back(fileRead8u(pFile)); // value 1 end + jEvent.push_back(fileRead8u(pFile)); // value 2 end if (nVersion > 7) { - uint8_t nStutterCount = fileRead8(pFile); - uint8_t nStutterDur = fileRead8(pFile); - pEvent->setStutterCount(nStutterCount); - pEvent->setStutterDur(nStutterDur); + // Read legacy values + uint8_t stut_cnt = fileRead8u(pFile); // Legacy stutter count + uint8_t stut_dur = fileRead8u(pFile); // Legacy stutter duration + if (stut_cnt > 0) { // Stutter speed calculated from legacy values + uint16_t legacy_clocks_step = 24 * jPattern.value("beats", 4) / jPattern.value("steps", 16); // 6 by default (96/16) => 4 steps/beat + jEvent.push_back(legacy_clocks_step / stut_cnt); + } else { + jEvent.push_back(0); + } + jEvent.push_back(0); // Stutter velocity FX nBlockSize -= 2; + } else { + jEvent.push_back(0); + jEvent.push_back(0); } - if (nVersion > 8) { - uint8_t nPlayChance = fileRead8(pFile); - pEvent->setPlayChance(nPlayChance); + jEvent.push_back(0); // Stutter Ramp + if (nVersion > 8) { // Play chance + jEvent.push_back(int(100 * fileReadBCD(pFile))); nBlockSize -= 1; + } else { + jEvent.push_back(100); } - fileRead8(pFile); // Padding + jEvent.push_back(1); // Play frequency + jEvent.push_back(100); // Stutter chance + jEvent.push_back(1); // Stutter frequency + fileRead8(pFile); // Padding nBlockSize -= 14; // printf(" Step:%u Duration:%u Command:%02X, Value1:%u..%u, Value2:%u..%u\n", nTime, nDuration, nCommand, nValue1start, nValue2end, // nValue2start, nValue2end); + jPattern["events"].push_back(jEvent); } - pPattern->resetSnapshots(); } } fclose(pFile); // printf("Ver: %d Loaded %lu pattern from file %s\n", nVersion, m_mPatterns.size(), filename); - return true; + std::string json_str = jPattern.dump(); + freeState(); + g_pState = (char*)malloc(json_str.size() + 1); + std::strcpy(g_pState, json_str.c_str()); + return g_pState; } -void save(const char* filename) { - //!@todo Need to save / load ticks per beat (unless we always use 1920) - FILE* pFile; - int nPos = 0; - pFile = fopen(filename, "w"); - if (pFile == NULL) { - fprintf(stderr, "ERROR: SequenceManager failed to open file %s\n", filename); - return; - } - uint32_t nBlockSize; - fwrite("vers", 4, 1, pFile); // IFF block name - nPos += 4; - nPos += fileWrite32(16, pFile); // IFF block size - nPos += fileWrite32(FILE_VERSION, pFile); // IFF block content - nPos += fileWrite16(uint16_t(g_dTempo), pFile); //!@todo Write current tempo - nPos += fileWrite16(g_nBeatsPerBar, pFile); //!@todo Write current beats per bar - nPos += fileWrite8(g_seqMan.getTriggerChannel(), pFile); - nPos += fileWrite8(g_seqMan.getTriggerDevice(), pFile); - nPos += fileWrite8('\0', pFile); // JACK output not yet implemented - nPos += fileWrite8('\0', pFile); - nPos += fileWrite16(g_nVerticalZoom, pFile); - nPos += fileWrite16(g_nHorizontalZoom, pFile); +void savePatternSnapshot() { + if (g_pPattern) + g_pPattern->saveSnapshot(); +} - // Iterate through patterns - uint32_t nPattern = 0; - do { - Pattern* pPattern = g_seqMan.getPattern(nPattern); - // Only save patterns with content - if (pPattern->getEventAt(0)) { - fwrite("patnxxxx", 8, 1, pFile); - nPos += 8; - uint32_t nStartOfBlock = nPos; - nPos += fileWrite32(nPattern, pFile); - nPos += fileWrite32(pPattern->getBeatsInPattern(), pFile); - nPos += fileWrite16(pPattern->getStepsPerBeat(), pFile); - nPos += fileWrite8(pPattern->getScale(), pFile); - nPos += fileWrite8(pPattern->getTonic(), pFile); - nPos += fileWrite8(pPattern->getRefNote(), pFile); - nPos += fileWrite8(pPattern->getQuantizeNotes(), pFile); - nPos += fileWrite8(pPattern->getSwingDiv(), pFile); - nPos += fileWriteBCD(pPattern->getSwingAmount(), pFile); - nPos += fileWriteBCD(pPattern->getHumanTime(), pFile); - nPos += fileWriteBCD(pPattern->getHumanVelo(), pFile); - nPos += fileWriteBCD(pPattern->getPlayChance(), pFile); - nPos += fileWrite8('\0', pFile); - uint32_t nEvent = 0; - while (StepEvent* pEvent = pPattern->getEventAt(nEvent++)) { - // Event Position (step) - nPos += fileWrite32(pEvent->getPosition(), pFile); - // Offset as BCD - nPos += fileWriteBCD(pEvent->getOffset(), pFile); - // Duration as BCD - nPos += fileWriteBCD(pEvent->getDuration(), pFile); - // 1 byte values - nPos += fileWrite8(pEvent->getCommand(), pFile); - nPos += fileWrite8(pEvent->getValue1start(), pFile); - nPos += fileWrite8(pEvent->getValue2start(), pFile); - nPos += fileWrite8(pEvent->getValue1end(), pFile); - nPos += fileWrite8(pEvent->getValue2end(), pFile); - nPos += fileWrite8(pEvent->getStutterCount(), pFile); - nPos += fileWrite8(pEvent->getStutterDur(), pFile); - nPos += fileWrite8(pEvent->getPlayChance(), pFile); - nPos += fileWrite8('\0', pFile); // Pad to even block (could do at end but simplest here) - } - nBlockSize = nPos - nStartOfBlock; - fseek(pFile, nStartOfBlock - 4, SEEK_SET); - fileWrite32(nBlockSize, pFile); - fseek(pFile, 0, SEEK_END); - } - nPattern = g_seqMan.getNextPattern(nPattern); - } while (nPattern != -1); - - // Iterate through banks - for (uint32_t nBank = 1; nBank < g_seqMan.getBanks(); ++nBank) { - uint32_t nSequences = g_seqMan.getSequencesInBank(nBank); - if (nSequences == 0) - continue; - fwrite("bankxxxx", 8, 1, pFile); - nPos += 8; - uint32_t nStartOfBlock = nPos; - nPos += fileWrite8(nBank, pFile); - nPos += fileWrite8(0, pFile); - nPos += fileWrite32(nSequences, pFile); - for (uint32_t nSequence = 0; nSequence < nSequences; ++nSequence) { - Sequence* pSequence = g_seqMan.getSequence(nBank, nSequence); - nPos += fileWrite8(pSequence->getPlayMode(), pFile); - nPos += fileWrite8(pSequence->getGroup(), pFile); - nPos += fileWrite8(g_seqMan.getTriggerNote(nBank, nSequence), pFile); - nPos += fileWrite8('\0', pFile); - std::string sName = pSequence->getName(); - for (size_t nIndex = 0; nIndex < sName.size(); ++nIndex) - nPos += fileWrite8(sName[nIndex], pFile); - for (size_t nIndex = sName.size(); nIndex < 16; ++nIndex) - nPos += fileWrite8('\0', pFile); - nPos += fileWrite32(pSequence->getTracks(), pFile); - for (size_t nTrack = 0; nTrack < pSequence->getTracks(); ++nTrack) { - Track* pTrack = pSequence->getTrack(nTrack); - if (pTrack) { - nPos += fileWrite8(pTrack->getType(), pFile); - nPos += fileWrite8(pTrack->getChainID(), pFile); - nPos += fileWrite8(pTrack->getChannel(), pFile); - nPos += fileWrite8(pTrack->getOutput(), pFile); - nPos += fileWrite8(pTrack->getMap(), pFile); - nPos += fileWrite8('\0', pFile); - nPos += fileWrite16(pTrack->getPatterns(), pFile); - for (uint16_t nPattern = 0; nPattern < pTrack->getPatterns(); ++nPattern) { - nPos += fileWrite32(pTrack->getPatternPositionByIndex(nPattern), pFile); - Pattern* pPattern = pTrack->getPatternByIndex(nPattern); - uint32_t nPatternId = g_seqMan.getPatternIndex(pPattern); - nPos += fileWrite32(nPatternId, pFile); - } - } else { - // Shouldn't need this but add empty tracks - nPos += fileWrite32(0, pFile); - nPos += fileWrite16(0, pFile); - } - } - Timebase* pTimebase = pSequence->getTimebase(); - if (pTimebase) { - nPos += fileWrite32(pTimebase->getEventQuant(), pFile); - for (uint32_t nIndex = 0; nIndex < pTimebase->getEventQuant(); ++nIndex) { - TimebaseEvent* pEvent = pTimebase->getEvent(nIndex); - nPos += fileWrite16(pEvent->bar, pFile); - nPos += fileWrite16(pEvent->clock, pFile); - nPos += fileWrite16(pEvent->type, pFile); - nPos += fileWrite16(pEvent->value, pFile); - } - } else { - nPos += fileWrite32(0, pFile); - } - } - nBlockSize = nPos - nStartOfBlock; - fseek(pFile, nStartOfBlock - 4, SEEK_SET); - fileWrite32(nBlockSize, pFile); - fseek(pFile, 0, SEEK_END); - } - - fclose(pFile); - g_bDirty = false; +void resetPatternSnapshots() { + if (g_pPattern) + g_pPattern->resetSnapshots(); } -void save_pattern(uint32_t nPattern, const char* filename) { - //!@todo Need to save / load ticks per beat (unless we always use 1920) - - Pattern* pPattern = g_seqMan.getPattern(nPattern); - // Only save pattern if it has content - if (isPatternEmpty(nPattern)) { - fprintf(stderr, "WARNING: SequenceManager don't save pattern %d because it's empty\n", nPattern); - return; - } - - FILE* pFile; - int nPos = 0; - pFile = fopen(filename, "w"); - if (pFile == NULL) { - fprintf(stderr, "ERROR: SequenceManager failed to open file %s\n", filename); - return; - } - - uint32_t nBlockSize; - fwrite("vers", 4, 1, pFile); // IFF block name - nPos += 4; - nPos += fileWrite32(10, pFile); // IFF block size - nPos += fileWrite32(FILE_VERSION, pFile); // IFF block content - nPos += fileWrite16(g_nBeatsPerBar, pFile); //!@todo Write current beats per bar - nPos += fileWrite16(g_nVerticalZoom, pFile); - nPos += fileWrite16(g_nHorizontalZoom, pFile); - - fwrite("patn", 4, 1, pFile); - nPos += 4; - nPos += fileWrite32(0, pFile); // IFF block size - uint32_t nStartOfBlock = nPos; - nPos += fileWrite32(pPattern->getBeatsInPattern(), pFile); - nPos += fileWrite16(pPattern->getStepsPerBeat(), pFile); - nPos += fileWrite8(pPattern->getScale(), pFile); - nPos += fileWrite8(pPattern->getTonic(), pFile); - nPos += fileWrite8(pPattern->getRefNote(), pFile); - nPos += fileWrite8(pPattern->getQuantizeNotes(), pFile); - nPos += fileWrite8(pPattern->getSwingDiv(), pFile); - nPos += fileWriteBCD(pPattern->getSwingAmount(), pFile); - nPos += fileWriteBCD(pPattern->getHumanTime(), pFile); - nPos += fileWriteBCD(pPattern->getHumanVelo(), pFile); - nPos += fileWriteBCD(pPattern->getPlayChance(), pFile); - nPos += fileWrite8('\0', pFile); - uint32_t nEvent = 0; - while (StepEvent* pEvent = pPattern->getEventAt(nEvent++)) { - // Event Position (step) - nPos += fileWrite32(pEvent->getPosition(), pFile); - // Offset as BCD - nPos += fileWriteBCD(pEvent->getOffset(), pFile); - // Duration as BCD - nPos += fileWriteBCD(pEvent->getDuration(), pFile); - // 1 byte values - nPos += fileWrite8(pEvent->getCommand(), pFile); - nPos += fileWrite8(pEvent->getValue1start(), pFile); - nPos += fileWrite8(pEvent->getValue2start(), pFile); - nPos += fileWrite8(pEvent->getValue1end(), pFile); - nPos += fileWrite8(pEvent->getValue2end(), pFile); - nPos += fileWrite8(pEvent->getStutterCount(), pFile); - nPos += fileWrite8(pEvent->getStutterDur(), pFile); - nPos += fileWrite8(pEvent->getPlayChance(), pFile); - nPos += fileWrite8('\0', pFile); // Pad to even block (could do at end but simplest here) - } - nBlockSize = nPos - nStartOfBlock; - fseek(pFile, nStartOfBlock - 4, SEEK_SET); - fileWrite32(nBlockSize, pFile); - fseek(pFile, 0, SEEK_END); - fclose(pFile); +bool undoPattern() { + if (g_pPattern) + return g_pPattern->undo(); + return false; } -void savePatternSnapshot() { g_seqMan.getPattern(g_nPattern)->saveSnapshot(); } - -void resetPatternSnapshots() { g_seqMan.getPattern(g_nPattern)->resetSnapshots(); } - -bool undoPattern() { return g_seqMan.getPattern(g_nPattern)->undo(); } - -bool redoPattern() { return g_seqMan.getPattern(g_nPattern)->redo(); } +bool redoPattern() { + if (g_pPattern) + return g_pPattern->redo(); + return false; +} -bool undoPatternAll() { return g_seqMan.getPattern(g_nPattern)->undoAll(); } +bool undoPatternAll() { + if (g_pPattern) + return g_pPattern->undoAll(); + return false; +} -bool redoPatternAll() { return g_seqMan.getPattern(g_nPattern)->redoAll(); } +bool redoPatternAll() { + if (g_pPattern) + return g_pPattern->redoAll(); + return false; +} -void setPatternZoom(int16_t zoom) { g_seqMan.getPattern(g_nPattern)->setZoom(zoom); } +void setPatternZoom(int16_t zoom) { + if (g_pPattern) + g_pPattern->setZoom(zoom); +} -int16_t getPatternZoom() { return g_seqMan.getPattern(g_nPattern)->getZoom(); } +int16_t getPatternZoom() { + if (g_pPattern) + return g_pPattern->getZoom(); + return 0; +} // ** This is not user by Pattern editor anymore. Is this used by arranger? ** @@ -1461,134 +1689,61 @@ void setHorizontalZoom(uint16_t zoom) { g_nHorizontalZoom = zoom; } // ** Direct MIDI interface ** -// Schedule a MIDI message to be sent in next JACK process cycle -void sendMidiMsg(MIDI_MESSAGE* pMsg) { +// Schedule a MIDI message to be sent in next JACK process period +void sendMidiMsg(MIDI_MESSAGE& msg) { // Find first available time slot - uint32_t time = jack_frames_since_cycle_start(g_pJackClient); + uint32_t tick = g_nBarStartTick + g_nTick;; while (g_bMutex) std::this_thread::sleep_for(std::chrono::milliseconds(1)); g_bMutex = true; - g_mSchedule.insert(std::pair(time, pMsg)); + g_mSchedule.insert(std::pair(tick, new SEQ_EVENT({tick, 0, msg}))); g_bMutex = false; } // Schedule a note off event after 'duration' ms void noteOffTimer(uint8_t note, uint8_t channel, uint32_t duration) { std::this_thread::sleep_for(std::chrono::milliseconds(duration)); - MIDI_MESSAGE* pMsg = new MIDI_MESSAGE; - pMsg->command = MIDI_NOTE_OFF | (channel & 0x0F); - pMsg->value1 = note; - pMsg->value2 = 0; - sendMidiMsg(pMsg); + MIDI_MESSAGE msg; + msg.command = MIDI_NOTE_OFF | (channel & 0x0F); + msg.value1 = note; + msg.value2 = 0; + sendMidiMsg(msg); } void playNote(uint8_t note, uint8_t velocity, uint8_t channel, uint32_t duration) { if (note > 127 || velocity > 127 || channel > 15 || duration > 60000) return; - MIDI_MESSAGE* pMsg = new MIDI_MESSAGE; - pMsg->command = MIDI_NOTE_ON | channel; - pMsg->value1 = note; - pMsg->value2 = velocity; - sendMidiMsg(pMsg); + MIDI_MESSAGE msg; + msg.command = MIDI_NOTE_ON | channel; + msg.value1 = note; + msg.value2 = velocity; + sendMidiMsg(msg); if (duration) { std::thread noteOffThread(noteOffTimer, note, channel, duration); noteOffThread.detach(); } } -//!@todo Do we still need functions to send MIDI transport control (start, stop, continuew, songpos, song select, clock)? - -void sendMidiStart() { - MIDI_MESSAGE* pMsg = new MIDI_MESSAGE; - pMsg->command = MIDI_START; - sendMidiMsg(pMsg); - DPRINTF("Sending MIDI Start... does it get recieved back???\n"); -} - -void sendMidiStop() { - MIDI_MESSAGE* pMsg = new MIDI_MESSAGE; - pMsg->command = MIDI_STOP; - sendMidiMsg(pMsg); -} - -void sendMidiContinue() { - MIDI_MESSAGE* pMsg = new MIDI_MESSAGE; - pMsg->command = MIDI_CONTINUE; - sendMidiMsg(pMsg); -} - -void sendMidiSongPos(uint16_t pos) { - MIDI_MESSAGE* pMsg = new MIDI_MESSAGE; - pMsg->command = MIDI_POSITION; - pMsg->value1 = pos & 0x7F; - pMsg->value2 = (pos >> 7) & 0x7F; - sendMidiMsg(pMsg); -} - -void sendMidiSong(uint32_t pos) { - if (pos > 127) - return; - MIDI_MESSAGE* pMsg = new MIDI_MESSAGE; - pMsg->command = MIDI_SONG; - pMsg->value1 = pos & 0x7F; - sendMidiMsg(pMsg); -} - -void sendMidiClock() { - MIDI_MESSAGE* pMsg = new MIDI_MESSAGE; - pMsg->command = MIDI_CLOCK; - sendMidiMsg(pMsg); -} - void sendMidiCommand(uint8_t status, uint8_t value1, uint8_t value2) { - MIDI_MESSAGE* pMsg = new MIDI_MESSAGE; - pMsg->command = status; - pMsg->value1 = value1; - pMsg->value2 = value2; - sendMidiMsg(pMsg); -} - -uint8_t getMidiClockOutput() { return g_bSendMidiClock; } - -void setMidiClockOutput(bool enable) { g_bSendMidiClock = enable; } - -uint8_t getTriggerDevice() { return g_seqMan.getTriggerDevice(); } - -void setTriggerDevice(uint8_t idev) { - g_seqMan.setTriggerDevice(idev); - g_bDirty = true; -} - -uint8_t getTriggerChannel() { return g_seqMan.getTriggerChannel(); } - -void setTriggerChannel(uint8_t channel) { - g_seqMan.setTriggerChannel(channel); - g_bDirty = true; + MIDI_MESSAGE msg; + msg.command = status; + msg.value1 = value1; + msg.value2 = value2; + sendMidiMsg(msg); } -uint8_t getTriggerNote(uint8_t bank, uint8_t sequence) { return g_seqMan.getTriggerNote(bank, sequence); } - -void setTriggerNote(uint8_t bank, uint8_t sequence, uint8_t note) { - g_seqMan.setTriggerNote(bank, sequence, note); - g_bDirty = true; -} - -uint16_t getTriggerSequence(uint8_t note) { return g_seqMan.getTriggerSequence(note); } - // ** Pattern management functions ** uint32_t createPattern() { return g_seqMan.createPattern(); } -void cleanPatterns() { g_seqMan.cleanPatterns(); } - -void toggleMute(uint8_t bank, uint8_t sequence, uint32_t track) { - Track* pTrack = g_seqMan.getSequence(bank, sequence)->getTrack(track); +void toggleMute(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track) { + Track* pTrack = g_seqMan.getSequence(scene, phrase, sequence)->getTrack(track); if (pTrack) pTrack->mute(!pTrack->isMuted()); } -bool isMuted(uint8_t bank, uint8_t sequence, uint32_t track) { - Track* pTrack = g_seqMan.getSequence(bank, sequence)->getTrack(track); +bool isMuted(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track) { + Track* pTrack = g_seqMan.getSequence(scene, phrase, sequence)->getTrack(track); if (pTrack) return pTrack->isMuted(); return false; @@ -1598,439 +1753,593 @@ void enableMidiRecord(bool enable) { g_bMidiRecord = enable; } bool isMidiRecord() { return g_bMidiRecord; } -void selectPattern(uint32_t pattern) { - g_nPattern = pattern; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, true); - addPattern(0, 0, 0, 0, g_nPattern, true); -} - bool isPatternEmpty(uint32_t pattern) { Pattern* pPattern = g_seqMan.getPattern(pattern); return pPattern->getEventAt(0) == NULL; } -uint32_t getPatternIndex() { return g_nPattern; } +void selectPattern(uint32_t pattern) { + g_pPattern = g_seqMan.getPattern(pattern); +} + +uint32_t getPatternIndex() { return g_seqMan.getPatternIndex(g_pPattern); } uint32_t getSteps() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getSteps(); - fprintf(stderr, "No pattern selected\n"); + if (g_pPattern) + return g_pPattern->getSteps(); return 0; } uint32_t getPatternLength(uint32_t pattern) { - Pattern* pPattern = g_seqMan.getPattern(g_nPattern); + Pattern* pPattern = g_seqMan.getPattern(pattern); if (pPattern) return pPattern->getLength(); return 0; } -uint32_t getBeatsInPattern() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getBeatsInPattern(); +uint8_t getNoteAtIndex(uint32_t pattern, uint32_t index) { + Pattern* pPattern = g_seqMan.getPattern(pattern); + if (!pPattern || index >= pPattern->getEvents()) + return 0xff; + StepEvent* pEvent = pPattern->getEventAt(index); + if (pEvent->getCommand() == MIDI_NOTE_ON) + return pEvent->getValue1start(); + return 0xff; +} + +uint32_t getBeatsInPattern(uint32_t pattern) { + Pattern* pPattern = g_seqMan.getPattern(pattern); + if (pPattern) + return pPattern->getBeatsInPattern(); return 0; } -void setBeatsInPattern(uint32_t beats) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - g_seqMan.getPattern(g_nPattern)->setBeatsInPattern(beats); - g_seqMan.updateAllSequenceLengths(); - setPatternModified(g_seqMan.getPattern(g_nPattern), true, true); - g_bDirty = true; +void setBeatsInPattern(uint32_t pattern, uint32_t beats) { + Pattern* pPattern = g_seqMan.getPattern(pattern); + if (pPattern) { + pPattern->setBeatsInPattern(beats); + g_seqMan.updateAllSequenceLengths(); + setPatternModified(pPattern, true, true); + g_bDirty = true; + } } -uint32_t getClocksPerStep() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getClocksPerStep(); +uint32_t getClocksPerStep(uint32_t pattern) { + Pattern* pPattern = g_seqMan.getPattern(pattern); + if (pPattern) + return pPattern->getClocksPerStep(); return 6; } uint32_t getStepsPerBeat() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getStepsPerBeat(); + if (g_pPattern) + return g_pPattern->getStepsPerBeat(); return 4; } void setStepsPerBeat(uint32_t steps) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - g_seqMan.getPattern(g_nPattern)->setStepsPerBeat(steps); - setPatternModified(g_seqMan.getPattern(g_nPattern), true, true); - g_bDirty = true; + if (g_pPattern) { + g_pPattern->setStepsPerBeat(steps); + setPatternModified(g_pPattern, true, true); + g_bDirty = true; + } } uint32_t getSwingDiv() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getSwingDiv(); + if (g_pPattern) + return g_pPattern->getSwingDiv(); return 1; } void setSwingDiv(uint32_t div) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - g_seqMan.getPattern(g_nPattern)->setSwingDiv(div); - // setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_bDirty = true; + if (g_pPattern) { + g_pPattern->setSwingDiv(div); + // setPatternModified(g_pPattern, true, false); + g_bDirty = true; + } } float getSwingAmount() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getSwingAmount(); + if (g_pPattern) + return g_pPattern->getSwingAmount(); return 0.0; } void setSwingAmount(float amount) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - g_seqMan.getPattern(g_nPattern)->setSwingAmount(amount); - // setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_bDirty = true; + if (g_pPattern) { + g_pPattern->setSwingAmount(amount); + // setPatternModified(g_pPattern, true, false); + g_bDirty = true; + } } float getHumanTime() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getHumanTime(); + if (g_pPattern) + return g_pPattern->getHumanTime(); return 0.0; } void setHumanTime(float amount) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - g_seqMan.getPattern(g_nPattern)->setHumanTime(amount); - // setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_bDirty = true; + if (g_pPattern) { + g_pPattern->setHumanTime(amount); + // setPatternModified(g_pPattern, true, false); + g_bDirty = true; + } } float getHumanVelo() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getHumanVelo(); + if (g_pPattern) + return g_pPattern->getHumanVelo(); return 0.0; } void setHumanVelo(float amount) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - g_seqMan.getPattern(g_nPattern)->setHumanVelo(amount); - // setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_bDirty = true; + if (g_pPattern) { + g_pPattern->setHumanVelo(amount); + // setPatternModified(g_pPattern, true, false); + g_bDirty = true; + } } float getPlayChance() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getPlayChance(); + if (g_pPattern) + return g_pPattern->getPlayChance(); return 0.0; } void setPlayChance(float chance) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - g_seqMan.getPattern(g_nPattern)->setPlayChance(chance); - // setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_bDirty = true; + if (g_pPattern) { + g_pPattern->setPlayChance(chance); + // setPatternModified(g_pPattern, true, false); + g_bDirty = true; + } } bool addNote(uint32_t step, uint8_t note, uint8_t velocity, float duration, float offset) { - if (!g_seqMan.getPattern(g_nPattern)) - return false; - if (g_seqMan.getPattern(g_nPattern)->addNote(step, note, velocity, duration, offset)) { - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_bDirty = true; - return true; + if (g_pPattern) { + if (g_pPattern->addNote(step, note, velocity, duration, offset)) { + setPatternModified(g_pPattern, true, false); + g_bDirty = true; + return true; + } } return false; } void removeNote(uint32_t step, uint8_t note) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->removeNote(step, note); - g_bDirty = true; + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->removeNote(step, note); + g_bDirty = true; + } +} + +void clearNotes() { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->clearNotes(); + g_bDirty = true; + } +} + +int32_t getEventDataAt(uint32_t index, StepEvent* data){ + if (g_pPattern) { + StepEvent* ev = g_pPattern->getEventAt(index); + if (ev) { + memcpy(data, ev, sizeof(StepEvent)); + return index; + } + } + return -1; +} + +int32_t getBufferEventDataAt(uint32_t index, StepEvent* data){ + if (g_pPattern) { + StepEvent* ev = g_pPatternBuffer->getEventAt(index); + if (ev) { + memcpy(data, ev, sizeof(StepEvent)); + return index; + } + } + return -1; +} + +int32_t getNoteIndex(uint32_t step, uint8_t note) { + if (g_pPattern) + return g_pPattern->getNoteIndex(step, note); + return -1; +} + +int32_t getNoteData(uint32_t step, uint8_t note, StepEvent* data, bool cp_buffer){ + if (g_pPattern) { + if (cp_buffer) + return g_pPatternBuffer->getNoteData(step, note, data); + else + return g_pPattern->getNoteData(step, note, data); + } + return -1; +} + +int32_t setNoteData(uint32_t step, uint8_t note, StepEvent* data){ + if (g_pPattern) { + int32_t i = g_pPattern->setNoteData(step, note, data); + if (i > 0) g_bDirty = true; + return i; + } + return -1; } int32_t getNoteStart(uint32_t step, uint8_t note) { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getNoteStart(step, note); + if (g_pPattern) + return g_pPattern->getNoteStart(step, note); return -1; } uint8_t getNoteVelocity(uint32_t step, uint8_t note) { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getNoteVelocity(step, note); + if (g_pPattern) + return g_pPattern->getNoteVelocity(step, note); return 0; } void setNoteVelocity(uint32_t step, uint8_t note, uint8_t velocity) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->setNoteVelocity(step, note, velocity); - g_bDirty = true; + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setNoteVelocity(step, note, velocity); + g_bDirty = true; + } } float getNoteOffset(uint32_t step, uint8_t note) { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getNoteOffset(step, note); - return 0; + if (g_pPattern) + return g_pPattern->getNoteOffset(step, note); + return 0.0; } void setNoteOffset(uint32_t step, uint8_t note, float offset) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->setNoteOffset(step, note, offset); - g_bDirty = true; + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setNoteOffset(step, note, offset); + g_bDirty = true; + } } bool addControl(uint32_t step, uint8_t control, uint8_t valueStart, uint8_t valueEnd, float duration, float offset) { - if (!g_seqMan.getPattern(g_nPattern)) - return false; - if (g_seqMan.getPattern(g_nPattern)->addControl(step, control, valueStart, valueEnd, duration, offset)) { - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_bDirty = true; - return true; + if (g_pPattern) { + if (g_pPattern->addControl(step, control, valueStart, valueEnd, duration, offset)) { + setPatternModified(g_pPattern, true, false); + g_bDirty = true; + return true; + } } return false; } void removeControl(uint32_t step, uint8_t control) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->removeControl(step, control); - g_bDirty = true; + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->removeControl(step, control); + g_bDirty = true; + } +} + +void clearControl(uint8_t control) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->clearControl(control); + g_bDirty = true; + } } int32_t getControlStart(uint32_t step, uint8_t control) { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getControlStart(step, control); + if (g_pPattern) + return g_pPattern->getControlStart(step, control); return -1; } float getControlDuration(uint32_t step, uint8_t control) { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getControlDuration(step, control); + if (g_pPattern) + return g_pPattern->getControlDuration(step, control); return 0; } uint8_t getControlValue(uint32_t step, uint8_t control) { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getControlValue(step, control); + if (g_pPattern) + return g_pPattern->getControlValue(step, control); + return 0; +} + +uint8_t getControlValueEnd(uint32_t step, uint8_t control) { + if (g_pPattern) + return g_pPattern->getControlValueEnd(step, control); return 0; } void setControlValue(uint32_t step, uint8_t control, uint8_t valueStart, uint8_t valueEnd) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->setControlValue(step, control, valueStart, valueEnd); - g_bDirty = true; + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setControlValue(step, control, valueStart, valueEnd); + g_bDirty = true; + } } float getControlOffset(uint32_t step, uint8_t control) { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getControlOffset(step, control); - return 0; + if (g_pPattern) + return g_pPattern->getControlOffset(step, control); + return 0.0; } void setControlOffset(uint32_t step, uint8_t control, float offset) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->setControlOffset(step, control, offset); - g_bDirty = true; -} - -uint8_t getStutterCount(uint32_t step, uint8_t note) { - if (!g_seqMan.getPattern(g_nPattern)) - return 0; - return g_seqMan.getPattern(g_nPattern)->getStutterCount(step, note); + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setControlOffset(step, control, offset); + g_bDirty = true; + } } -void setStutterCount(uint32_t step, uint8_t note, uint8_t count) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->setStutterCount(step, note, count); - g_bDirty = true; +void setNoteStutter(uint32_t step, uint8_t note, uint8_t speed, uint8_t velfx, uint8_t ramp) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setStutter(step, note, speed, velfx, ramp); + g_bDirty = true; + } } -uint8_t getStutterDur(uint32_t step, uint8_t note) { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getStutterDur(step, note); +uint8_t getNoteStutterSpeed(uint32_t step, uint8_t note) { + if (g_pPattern) + return g_pPattern->getStutterSpeed(step, note); return 0; } -void setStutterDur(uint32_t step, uint8_t note, uint8_t dur) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->setStutterDur(step, note, dur); - g_bDirty = true; +void setNoteStutterSpeed(uint32_t step, uint8_t note, uint8_t speed) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setStutterSpeed(step, note, speed); + g_bDirty = true; + } } -uint8_t getNotePlayChance(uint32_t step, uint8_t note) { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getPlayChance(step, note); - return 100; +uint8_t getNoteStutterVelfx(uint32_t step, uint8_t note) { + if (g_pPattern) + return g_pPattern->getStutterVelfx(step, note); + return 0; } -void setNotePlayChance(uint32_t step, uint8_t note, uint8_t chance) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->setPlayChance(step, note, chance); - g_bDirty = true; +void setNoteStutterVelfx(uint32_t step, uint8_t note, uint8_t velfx) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setStutterVelfx(step, note, velfx); + g_bDirty = true; + } } -float getNoteDuration(uint32_t step, uint8_t note) { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getNoteDuration(step, note); +uint8_t getNoteStutterRamp(uint32_t step, uint8_t note) { + if (g_pPattern) + return g_pPattern->getStutterRamp(step, note); return 0; } -bool addProgramChange(uint32_t step, uint8_t program) { - if (!g_seqMan.getPattern(g_nPattern)) - return false; - if (g_seqMan.getPattern(g_nPattern)->addProgramChange(step, program)) { - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); +void setNoteStutterRamp(uint32_t step, uint8_t note, uint8_t ramp) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setStutterRamp(step, note, ramp); g_bDirty = true; - return true; } - return false; } -void removeProgramChange(uint32_t step, uint8_t program) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - if (g_seqMan.getPattern(g_nPattern)->removeProgramChange(step)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_bDirty = true; +float getNotePlayChance(uint32_t step, uint8_t note) { + if (g_pPattern) + return g_pPattern->getPlayChance(step, note); + return 1.0; } -uint8_t getProgramChange(uint32_t step) { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getProgramChange(step); - return 0xFF; +void setNotePlayChance(uint32_t step, uint8_t note, float chance) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setPlayChance(step, note, chance); + g_bDirty = true; + } } -void transpose(int8_t value) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->transpose(value); - g_bDirty = true; +uint8_t getNotePlayFreq(uint32_t step, uint8_t note) { + if (g_pPattern) + return g_pPattern->getPlayFreq(step, note); + return 1.0; } -void changeVelocityAll(int value) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->changeVelocityAll(value); - g_bDirty = true; +void setNotePlayFreq(uint32_t step, uint8_t note, uint8_t freq) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setPlayFreq(step, note, freq); + g_bDirty = true; + } } -void changeDurationAll(float value) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->changeDurationAll(value); - g_bDirty = true; +float getNoteStutterChance(uint32_t step, uint8_t note) { + if (g_pPattern) + return g_pPattern->getStutterChance(step, note); + return 1.0; } -void changeStutterCountAll(int value) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->changeStutterCountAll(value); - g_bDirty = true; +void setNoteStutterChance(uint32_t step, uint8_t note, float chance) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setStutterChance(step, note, chance); + g_bDirty = true; + } } -void changeStutterDurAll(int value) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->changeStutterDurAll(value); - g_bDirty = true; +uint8_t getNoteStutterFreq(uint32_t step, uint8_t note) { + if (g_pPattern) + return g_pPattern->getStutterFreq(step, note); + return 1.0; } -void clear() { - if (!g_seqMan.getPattern(g_nPattern)) - return; - setPatternModified(g_seqMan.getPattern(g_nPattern), true, false); - g_seqMan.getPattern(g_nPattern)->clear(); - // g_seqMan.getPattern(g_nPattern)->resetSnapshots(); - g_bDirty = true; +void setNoteStutterFreq(uint32_t step, uint8_t note, uint8_t freq) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->setStutterFreq(step, note, freq); + g_bDirty = true; + } } -void copyPattern(uint32_t source, uint32_t destination) { - g_seqMan.copyPattern(source, destination); - g_bDirty = true; +float getNoteDuration(uint32_t step, uint8_t note) { + if (g_pPattern) + return g_pPattern->getNoteDuration(step, note); + return 0.0; } -void setInputRest(uint8_t note) { - if (note > 127) - g_nInputRest = 0xFF; - g_nInputRest = note; - g_bDirty = true; +bool addProgramChange(uint32_t step, uint8_t program) { + if (g_pPattern) { + if (g_pPattern->addProgramChange(step, program)) { + setPatternModified(g_pPattern, true, false); + g_bDirty = true; + return true; + } + } + return false; } -uint8_t getInputRest() { return g_nInputRest; } - -void setScale(uint32_t scale) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - if (scale != g_seqMan.getPattern(g_nPattern)->getScale()) +void removeProgramChange(uint32_t step) { + if (g_pPattern) { + if (g_pPattern->removeProgramChange(step)) + return; + setPatternModified(g_pPattern, true, false); g_bDirty = true; - g_seqMan.getPattern(g_nPattern)->setScale(scale); + } } -uint32_t getScale() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getScale(); - return 0; +uint8_t getProgramChange(uint32_t step) { + if (g_pPattern) + return g_pPattern->getProgramChange(step); + return 0xFF; } -void setTonic(uint8_t tonic) { - if (!g_seqMan.getPattern(g_nPattern)) - return; - g_seqMan.getPattern(g_nPattern)->setTonic(tonic); - g_bDirty = true; +void transpose(int8_t value) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->transpose(value); + g_bDirty = true; + } +} + +void changeVelocityAll(int value) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->changeVelocityAll(value); + g_bDirty = true; + } +} + +void changeVelocityList(float value, uint32_t* evi_list, uint32_t n) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->changeVelocityList(value, evi_list, n); + g_bDirty = true; + } +} + +void changeDurationAll(float value) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->changeDurationAll(value); + g_bDirty = true; + } +} + +void changeDurationList(float value, uint32_t* evi_list, uint32_t n) { + if (g_pPattern) { + setPatternModified(g_pPattern, true, false); + g_pPattern->changeDurationList(value, evi_list, n); + g_bDirty = true; + } +} + +void setScale(uint32_t scale) { + if (g_pPattern) { + if (scale != g_pPattern->getScale()) + g_bDirty = true; + g_pPattern->setScale(scale); + } +} + +uint32_t getScale() { + if (g_pPattern) + return g_pPattern->getScale(); + return 0; +} + +void setTonic(uint8_t tonic) { + if (g_pPattern) { + g_pPattern->setTonic(tonic); + g_bDirty = true; + } } uint8_t getTonic() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getTonic(); + if (g_pPattern) + return g_pPattern->getTonic(); return 0; } -void setPatternModified(Pattern* pPattern, bool bModified, bool bModifiedTracks) { - if (bModified && bModifiedTracks) { - for (uint32_t nBank = 1; nBank < g_seqMan.getBanks(); ++nBank) { - for (uint32_t nSequence = 0; nSequence < g_seqMan.getSequencesInBank(nBank); ++nSequence) { - Sequence* pSequence = g_seqMan.getSequence(nBank, nSequence); - if (!pSequence) - continue; - bool bFound = false; - for (uint32_t nTrack = 0; nTrack < getTracksInSequence(nBank, nSequence) && !bFound; ++nTrack) { - Track* pTrack = g_seqMan.getSequence(nBank, nSequence)->getTrack(nTrack); - for (uint32_t nPattern = 0; nPattern < pTrack->getPatterns() && !bFound; ++nPattern) { - if (pTrack->getPatternByIndex(nPattern) == pPattern) - bFound = true; - } - if (bFound) { - pTrack->setModified(); - pSequence->setModified(); - } - } - } +void clearPattern(uint32_t pattern) { + Pattern* pPattern = g_seqMan.getPattern(pattern); + if (pPattern) { + setPatternModified(pPattern, true, false); + pPattern->clear(); + // pPattern->resetSnapshots(); + g_bDirty = true; + } +} + +void copyPattern(uint32_t source, uint32_t destination) { + g_seqMan.copyPattern(source, destination); + g_bDirty = true; +} + +void pastePatternBuffer(uint32_t pattern, int32_t dstep, float doffset, int8_t dnote, bool truncate) { + Pattern* pPattern = g_seqMan.getPattern(pattern); + if (pPattern) { + if (g_pPatternBuffer) { + pPattern->pastePattern(g_pPatternBuffer, dstep, doffset, dnote, truncate); + g_bDirty = true; } } - g_bPatternModified = bModified; } +uint32_t copyPatternBuffer(uint32_t pattern, uint32_t step1, uint32_t step2, uint8_t note1, uint8_t note2, bool cut) { + Pattern* pPattern = g_seqMan.getPattern(pattern); + if (pPattern) { + // Free last selection + if (g_pPatternBuffer) + delete g_pPatternBuffer; + // Copy new selection to buffer + g_pPatternBuffer = pPattern->getPatternSelection(step1, step2, note1, note2, cut); + // If something was cutted ... + uint32_t n = g_pPatternBuffer->getEvents(); + if (cut && n > 0) + g_bDirty = true; + return n; + } + return 0; +} + +uint32_t getPatternSelectionIndexes(uint32_t pattern, uint32_t* ev_indexes, uint32_t limit, uint32_t step1, uint32_t step2, uint8_t note1, uint8_t note2) { + Pattern* pPattern = g_seqMan.getPattern(pattern); + if (pPattern) { + return pPattern->getPatternSelectionIndexes(ev_indexes, limit, step1, step2, note1, note2); + } + return 0; +} + +void setInputRest(uint8_t note) { + if (note > 127) + g_nInputRest = 0xFF; + g_nInputRest = note; + g_bDirty = true; +} + +uint8_t getInputRest() { return g_nInputRest; } + bool isPatternModified() { if (g_bPatternModified) { g_bPatternModified = false; @@ -2040,65 +2349,99 @@ bool isPatternModified() { } uint8_t getRefNote() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getRefNote(); + if (g_pPattern) + return g_pPattern->getRefNote(); return 60; } void setRefNote(uint8_t note) { - if (g_seqMan.getPattern(g_nPattern)) - g_seqMan.getPattern(g_nPattern)->setRefNote(note); + if (g_pPattern) + g_pPattern->setRefNote(note); +} + +uint8_t getQuantizeNotes() { + if (g_pPattern) + return g_pPattern->getQuantizeNotes(); + return false; +} + +void setQuantizeNotes(uint8_t qn) { + if (g_pPattern) + g_pPattern->setQuantizeNotes(qn); } -bool getQuantizeNotes() { - if (g_seqMan.getPattern(g_nPattern)) - return g_seqMan.getPattern(g_nPattern)->getQuantizeNotes(); +void setInterpolateCC(uint8_t ccnum, bool flag) { + if (g_pPattern) + g_pPattern->setInterpolateCC(ccnum, flag); +} + +bool getInterpolateCC(uint8_t ccnum) { + if (g_pPattern) + return g_pPattern->getInterpolateCC(ccnum); return false; } -void setQuantizeNotes(bool flag) { - if (g_seqMan.getPattern(g_nPattern)) - g_seqMan.getPattern(g_nPattern)->setQuantizeNotes(flag); +void setInterpolateCCDefaults() { + if (g_pPattern) + g_pPattern->setInterpolateCCDefaults(); } uint32_t getLastStep() { - if (!g_seqMan.getPattern(g_nPattern)) - return -1; - return g_seqMan.getPattern(g_nPattern)->getLastStep(); + if (g_pPattern) + return g_pPattern->getLastStep(); + return -1; } uint32_t getPatternPlayhead() { - if (!g_pSequence) - return 0; - return g_pSequence->getPlayPosition() / getClocksPerStep(); + if (g_pPattern) + return g_seqMan.getSequence(g_nScene, g_nPhrase, g_nSequence)->getPlayPosition() / g_pPattern->getClocksPerStep(); + return 0; +} + +void setPatternModified(Pattern* pPattern, bool bModified, bool bModifiedTracks) { + if (bModified && bModifiedTracks) + g_seqMan.setPatternModified(pPattern); + g_bPatternModified = bModified; } // ** Sequence management functions ** -bool addPattern(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position, uint32_t pattern, bool force) { - bool bUpdated = g_seqMan.addPattern(bank, sequence, track, position, pattern, force); - if (bank + sequence) +bool addPattern(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint32_t position, uint32_t pattern, bool force) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + bool bUpdated = false; + if (pSequence) { + bUpdated = g_seqMan.addPattern(pSequence, track, position, pattern, force); g_bDirty |= bUpdated; + } return bUpdated; } -void removePattern(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position) { - g_seqMan.removePattern(bank, sequence, track, position); - g_bDirty = true; +void removePattern(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint32_t position) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + g_seqMan.removePattern(pSequence, track, position); + g_bDirty = true; + } } -uint32_t getPattern(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - Track* pTrack = pSequence->getTrack(track); +uint32_t getPattern(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint32_t position) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence == nullptr) + return 0xffffffff; + Track* pTrack = pSequence->getTrack(track); if (!pTrack) - return -1; + return 0xffffffff; Pattern* pPattern = pTrack->getPattern(position); + if (!pPattern) + return 0xffffffff; return g_seqMan.getPatternIndex(pPattern); } -uint32_t getPatternAt(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - Track* pTrack = pSequence->getTrack(track); +uint32_t getPatternAt(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint32_t position) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence == nullptr) + return -1; + Track* pTrack = pSequence->getTrack(track); if (!pTrack) return -1; Pattern* pPattern = pTrack->getPatternAt(position); @@ -2107,408 +2450,543 @@ uint32_t getPatternAt(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t p return g_seqMan.getPatternIndex(pPattern); } -uint8_t getPlayMode(uint8_t bank, uint8_t sequence) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - return pSequence->getPlayMode(); +uint8_t getSequenceMode(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + return pSequence->getPlayMode(); + return 0; +} + +void setSequenceMode(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t mode) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + pSequence->setPlayMode(mode); + g_bDirty = true; + } +} + +uint8_t getPlayState(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + return pSequence->getPlayState(); + else + return STOPPED; } -void setPlayMode(uint8_t bank, uint8_t sequence, uint8_t mode) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - pSequence->setPlayMode(mode); - if (bank + sequence) +void setSequenceRepeat(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t repeat) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + pSequence->setRepeat(repeat); g_bDirty = true; + } +} + +uint8_t getSequenceRepeat(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + return pSequence->getRepeat(); + } + return 0; +} + +void setSequenceTempo(uint8_t scene, uint8_t phrase, uint8_t sequence, float tempo) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + pSequence->setTempo(tempo); +} + +float getSequenceTempo(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + return pSequence->getTempo(); + return 0.0f; +} + +void setSequenceBpb(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t bpb) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + pSequence->setTimeSig(bpb); +} + +uint8_t getSequenceBpb(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + return pSequence->getTimeSig(); + return 0; } -uint8_t getPlayState(uint8_t bank, uint8_t sequence) { return g_seqMan.getSequence(bank, sequence)->getPlayState(); } +bool selectSequence(uint8_t scene, uint8_t phrase, uint8_t sequence) { + if (g_seqMan.getSequence(scene, phrase, sequence) == nullptr) + return false; + g_nPhrase = phrase; + g_nSequence = sequence; + return true; +} -bool isEmpty(uint8_t bank, uint8_t sequence) { return g_seqMan.getSequence(bank, sequence)->isEmpty(); } +bool isEmpty(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + return pSequence->isEmpty(); + return true; +} -void setPlayState(uint8_t bank, uint8_t sequence, uint8_t state) { - if (g_nPlayingSequences == 0) { - if (state == STARTING) { - if (g_nClockSource & TRANSPORT_CLOCK_INTERNAL) - setTransportToStartOfBar(); - transportStart("zynseq"); - } else if (state == STOPPING) - state = STOPPED; +void setPlayState(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t state) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence == nullptr) + return; + if (state == STARTING || state == PLAYING) { + // If no playing sequences, set BPB to the sequence's phrase's timesig + // This is disabled. We could want to enable it in the future, or not ;-) + //if (g_seqMan.getPlayingSequencesCount() == 0) { + // setBpb(getPhraseBPB(scene, phrase)); + //} + transportStart(TRANSPORT_CLIENT_ZYNSEQ); } - g_seqMan.setSequencePlayState(bank, sequence, state); + else if (!g_nPlayingSequences && state == STOPPING) + state = STOPPED; + g_seqMan.setPlayState(pSequence, state); } -void togglePlayState(uint8_t bank, uint8_t sequence) { - if (g_seqMan.getSequence(bank, sequence)->getPlayMode() == DISABLED) +void togglePlayState(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (!pSequence) + return; + if (pSequence->getRepeat() == 0) { + g_seqMan.stopGroup(pSequence->getGroup()); return; - uint8_t nState = g_seqMan.getSequence(bank, sequence)->getPlayState(); + } + uint8_t nState = pSequence->getPlayState(); switch (nState) { case STOPPED: nState = STARTING; break; case STARTING: - case RESTARTING: nState = STOPPED; break; case PLAYING: - nState = STOPPING; + nState = STOPPING_SYNC; break; case STOPPING: + case STOPPING_SYNC: nState = PLAYING; break; + case CHILD_PLAYING: + nState = CHILD_STOPPING; + break; + case CHILD_STOPPING: + nState = STARTING; + break; } - setPlayState(bank, sequence, nState); + setPlayState(scene, phrase, sequence, nState); } -uint32_t getSequenceState(uint8_t bank, uint8_t sequence) { return g_seqMan.getSequence(bank, sequence)->getState(); } +uint32_t getSequenceState(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + return pSequence->getState(); + return 0; +} -uint8_t getStateChange(uint8_t bank, uint8_t start, uint8_t end, uint32_t* states) { - uint8_t count = 0; - Sequence* pSequence; - for (uint8_t sequence = start; sequence < end; ++sequence) { - pSequence = g_seqMan.getSequence(bank, sequence); - if (pSequence->isModified()) - states[count++] = (pSequence->getState() & 0xffffff) | uint32_t(sequence << 24); +uint32_t getStateChange(uint32_t* states, uint32_t size) { + if (size == 0) + return 0; + uint32_t count = 0; + uint8_t phrase = 0; + uint8_t channel = 0; + while (Sequence* pPhraseSequence = g_seqMan.getSequence(g_nScene, phrase, PHRASE_CHANNEL)) { + for (uint8_t channel = 0; channel < 32; ++channel) { + Sequence* pSequence = pPhraseSequence->m_aChildSequences[channel]; + if (pSequence && pSequence->isModified()) { + states[count] = (phrase << 24) | (channel << 16) | (pSequence->getState() & 0xffff); + if (++count >= size) + return count; + } + } + if (pPhraseSequence->isModified()) + states[count++] = (phrase << 24) | (PHRASE_CHANNEL << 16) | (pPhraseSequence->getState() & 0xffff); + if (count >= size) + return count; + ++phrase; } return count; } -uint8_t getProgress(uint8_t bank, uint8_t start, uint8_t end, uint16_t* progress) { - uint8_t count = 0; - Sequence* pSequence; - for (uint8_t sequence = start; sequence < end; ++sequence) { - pSequence = g_seqMan.getSequence(bank, sequence); - if (pSequence->getLength()) - progress[count++] = (100 * pSequence->getPlayPosition() / pSequence->getLength()) & 0xff | uint32_t(sequence << 8); - } - return count; +uint8_t* getProgress() { + return g_seqMan.getProgress(); } -void stop() { g_seqMan.stop(); } +uint32_t getBeat() { + return g_nBeat; +} -uint32_t getPlayPosition(uint8_t bank, uint8_t sequence) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - return pSequence->getPlayPosition(); +void stop() { + g_seqMan.stop(); + g_mSchedule.clear(); } -void setPlayPosition(uint8_t bank, uint8_t sequence, uint32_t clock) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - pSequence->setPlayPosition(clock); +uint32_t getSequencePlayPosition(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + return pSequence->getPlayPosition(); + } + return 0; } -uint32_t getSequenceLength(uint8_t bank, uint8_t sequence) { return g_seqMan.getSequence(bank, sequence)->getLength(); } +void setSequencePlayPosition(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t clock) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + pSequence->setPlayPosition(clock); + } +} -void clearSequence(uint8_t bank, uint8_t sequence) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - pSequence->clear(); - g_bDirty = true; +uint32_t getSequenceLength(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + return pSequence->getLength(); + } + return 0; } -size_t getPlayingSequences() { return g_nPlayingSequences; } +void setSequenceLength(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t length) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + pSequence->updateLength(length); +} -void setSequencesInBank(uint8_t bank, uint8_t sequences) { - while (g_bMutex) - std::this_thread::sleep_for(std::chrono::microseconds(10)); - g_bMutex = true; - g_seqMan.setSequencesInBank(bank, sequences); - g_bMutex = false; - g_pSequence = g_seqMan.getSequence(bank, 0); +void clearSequence(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + pSequence->clear(); + g_bDirty = true; + } } -uint32_t getSequencesInBank(uint32_t bank) { return g_seqMan.getSequencesInBank(bank); } +size_t getPlayingSequences() { + return g_seqMan.getPlayingSequencesCount(); +} // ** Sequence management functions ** -uint8_t getGroup(uint8_t bank, uint8_t sequence) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - return pSequence->getGroup(); +uint8_t getSequenceGroup(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + return pSequence->getGroup(); + } + return 0; } -void setGroup(uint8_t bank, uint8_t sequence, uint8_t group) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - return pSequence->setGroup(group); - g_bDirty = true; +void setSequenceGroup(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t group) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + pSequence->setGroup(group); + g_bDirty = true; + } } -bool hasSequenceChanged(uint8_t bank, uint8_t sequence) { return g_seqMan.getSequence(bank, sequence)->isModified(); } +bool hasSequenceChanged(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + return pSequence->isModified(); + } + return false; +} -uint32_t addTrackToSequence(uint8_t bank, uint8_t sequence, uint32_t track) { +uint32_t addTrackToSequence(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track) { g_bDirty = true; - return g_seqMan.getSequence(bank, sequence)->addTrack(track); + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + return pSequence->addTrack(track); + return 0; } -void removeTrackFromSequence(uint8_t bank, uint8_t sequence, uint32_t track) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - if (!pSequence->removeTrack(track)) - return; +void removeTrackFromSequence(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + if (!pSequence->removeTrack(track)) + return; + } pSequence->updateLength(); g_bDirty = true; } -void addTempoEvent(uint8_t bank, uint8_t sequence, uint32_t tempo, uint16_t bar, uint16_t tick) { - //!@todo Concert tempo events to use double for tempo value - g_seqMan.getSequence(bank, sequence)->addTempo(tempo, bar, tick); +void addTempoEvent(uint8_t scene, uint8_t phrase, uint8_t sequence, float tempo, uint16_t bar, uint16_t tick) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + pSequence->addTempo(tempo, bar, tick); + } g_bDirty = true; } -uint32_t getTempoAt(uint8_t bank, uint8_t sequence, uint16_t bar, uint16_t tick) { return g_seqMan.getSequence(bank, sequence)->getTempo(bar, tick); } +void removeTempoEvent(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t bar, uint16_t tick) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + pSequence->removeTempo(bar, tick); + } + g_bDirty = true; +} -void addTimeSigEvent(uint8_t bank, uint8_t sequence, uint8_t beats, uint8_t type, uint16_t bar) { +float getTempoAt(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t bar, uint16_t tick) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + return pSequence->getTempoAt(bar, tick); + } + return 0.0f; +} + +void addTimeSigEvent(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t bar, uint8_t timeSig) { if (bar < 1) bar = 1; - g_seqMan.getSequence(bank, sequence)->addTimeSig((beats << 8) | type, bar); + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + pSequence->addTimeSig(timeSig, bar); + } g_bDirty = true; } -uint16_t getTimeSigAt(uint8_t bank, uint8_t sequence, uint16_t bar) { return g_seqMan.getSequence(bank, sequence)->getTimeSig(bar); } - -uint8_t getBeatsPerBar(uint8_t bank, uint8_t sequence, uint16_t bar) { return getTimeSigAt(bank, sequence, bar) >> 8; } +void removeTimeSigEvent(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t bar) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + pSequence->removeTimeSig(bar); + g_bDirty = true; +} -uint32_t getTracksInSequence(uint8_t bank, uint8_t sequence) { return g_seqMan.getSequence(bank, sequence)->getTracks(); } +uint8_t getTimeSigAt(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t bar) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + return pSequence->getTimeSigAt(bar); + return 0; +} -void setSequence(uint8_t bank, uint8_t sequence) { g_pSequence = g_seqMan.getSequence(bank, sequence); } +uint32_t getTracksInSequence(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + return pSequence->getTracks(); + return 0; +} -void setSequenceName(uint8_t bank, uint8_t sequence, const char* name) { g_seqMan.getSequence(bank, sequence)->setName(std::string(name)); } +void setSequenceName(uint8_t scene, uint8_t phrase, uint8_t sequence, const char* name) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + pSequence->setName(std::string(name)); +} -const char* getSequenceName(uint8_t bank, uint8_t sequence) { - strcpy(g_sName, g_seqMan.getSequence(bank, sequence)->getName().c_str()); +const char* getSequenceName(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + strncpy(g_sName, pSequence->getName().c_str(), sizeof(g_sName) - 1); + g_sName[sizeof(g_sName) - 1] = 0; // Ensure null termination + } else { + g_sName[0] = 0; + } return g_sName; } -bool moveSequence(uint8_t bank, uint8_t sequence, uint8_t position) { - bool bResult = g_seqMan.moveSequence(bank, sequence, position); - g_pSequence = g_seqMan.getSequence(0, 0); - return bResult; +void setSequenceFollowAction(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t action) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + g_bDirty |= g_seqMan.setFollowAction(scene, pSequence, action, pSequence->getFollowParam()); } -void insertSequence(uint8_t bank, uint8_t sequence) { - g_seqMan.insertSequence(bank, sequence); - g_pSequence = g_seqMan.getSequence(0, 0); +void setSequenceFollowParam(uint8_t scene, uint8_t phrase, uint8_t sequence, int16_t param) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + g_bDirty |= g_seqMan.setFollowAction(scene, pSequence, pSequence->getFollowAction(), param); } -void removeSequence(uint8_t bank, uint8_t sequence) { - g_seqMan.removeSequence(bank, sequence); - g_pSequence = g_seqMan.getSequence(0, 0); +uint8_t getSequenceFollowAction(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + return pSequence->getFollowAction(); + return FOLLOW_ACTION_NONE; } -void updateSequenceInfo() { g_seqMan.updateAllSequenceLengths(); } - -// ** Track management ** - -uint32_t getPatternsInTrack(uint8_t bank, uint8_t sequence, uint32_t track) { - Track* pTrack = g_seqMan.getSequence(bank, sequence)->getTrack(track); - if (!pTrack) - return 0; - return pTrack->getPatterns(); +int16_t getSequenceFollowParam(uint8_t scene, uint8_t phrase, uint8_t sequence) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) + return pSequence->getFollowParam(); + return 0; } -void setTrackType(uint8_t bank, uint8_t sequence, uint32_t track, uint8_t type) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - Track* pTrack = pSequence->getTrack(track); - if (!pTrack) - return; - pTrack->setType(type); - if (bank + sequence) - g_bDirty = true; +void updateSequenceInfo() { + g_seqMan.updateAllSequenceLengths(); } -uint8_t getTrackType(uint8_t bank, uint8_t sequence, uint32_t track) { - Track* pTrack = g_seqMan.getSequence(bank, sequence)->getTrack(track); - if (!pTrack) - return 0xFF; - return pTrack->getType(); -} +// ** Scene management ** -void setChainID(uint8_t bank, uint8_t sequence, uint32_t track, uint8_t chain_id) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - Track* pTrack = pSequence->getTrack(track); - if (!pTrack) - return; - pTrack->setChainID(chain_id); - if (bank + sequence) - g_bDirty = true; +bool setScene(uint8_t scene) { + while (g_bMutex) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + g_bMutex = true; + bool bCreated = g_seqMan.setScene(scene); + g_bMutex = false; + g_nScene = scene; + return bCreated; } -uint8_t getChainID(uint8_t bank, uint8_t sequence, uint32_t track) { - Track* pTrack = g_seqMan.getSequence(bank, sequence)->getTrack(track); - if (!pTrack) - return 0xFF; - return pTrack->getChainID(); +uint8_t getScene() { + return g_nScene; } -void setChannel(uint8_t bank, uint8_t sequence, uint32_t track, uint8_t channel) { - Sequence* pSequence = g_seqMan.getSequence(bank, sequence); - Track* pTrack = pSequence->getTrack(track); - if (!pTrack) - return; - pTrack->setChannel(channel); - if (bank + sequence) - g_bDirty = true; +uint8_t getNumScenes() { + return g_seqMan.getNumScenes(); } -uint8_t getChannel(uint8_t bank, uint8_t sequence, uint32_t track) { - Track* pTrack = g_seqMan.getSequence(bank, sequence)->getTrack(track); - if (!pTrack) - return 0xFF; - return pTrack->getChannel(); +void removeScene(uint8_t scene) { + g_seqMan.removeScene(scene); } -void solo(uint8_t bank, uint8_t sequence, uint32_t track, bool solo) { - Track* pTrack = g_seqMan.getSequence(bank, sequence)->getTrack(track); - if (!pTrack) - return; - pTrack->solo(); -} +// ** Track management ** -bool isSolo(uint8_t bank, uint8_t sequence, uint32_t track) { - Track* pTrack = g_seqMan.getSequence(bank, sequence)->getTrack(track); - if (!pTrack) - return false; - return pTrack->isSolo(); +uint32_t getPatternsInTrack(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + Track* pTrack = pSequence->getTrack(track); + if (!pTrack) + return 0; + return pTrack->getPatterns(); + } + return 0; } -// ** Transport management **/ +void setTrackOutput(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint8_t output) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + Track* pTrack = pSequence->getTrack(track); + if (pTrack) { + pTrack->setOutput(output); + g_bDirty = true; + } + } +} -void setTransportToStartOfBar() { - jack_position_t position; - jack_transport_query(g_pJackClient, &position); - position.beat = 1; - position.tick = 0; - // position.valid = JackPositionBBT; - jack_transport_reposition(g_pJackClient, &position); - // g_pNextTimebaseEvent = g_pTimebase->getPreviousTimebaseEvent(position.bar, 1, TIMEBASE_TYPE_ANY); //!@todo Might miss event if 2 at start of bar +uint8_t getTrackOutput(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + Track* pTrack = pSequence->getTrack(track); + if (pTrack) { + return pTrack->getOutput(); + } + } + return 0xFF; } -void transportLocate(uint32_t frame) { jack_transport_locate(g_pJackClient, frame); } +void setChannel(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint8_t channel) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + Track* pTrack = pSequence->getTrack(track); + if (pTrack) { + pTrack->setChannel(channel); + g_bDirty = true; + } + } +} -/* Calculate the song position in frames from BBT - */ -jack_nframes_t transportGetLocation(uint32_t bar, uint32_t beat, uint32_t tick) { - // Convert one-based bars and beats to zero-based - if (bar > 0) - --bar; - if (beat > 0) - --beat; - uint32_t nTicksToPrev = 0; - uint32_t nTicksToEvent = 0; - uint32_t nTicksPerBar = g_nTicksPerBeat * g_nBeatsPerBar; - //!@todo Handle changes in tempo and time signature - // jack_nframes_t nFramesPerTick = getFramesPerTick(DEFAULT_TEMPO); - jack_nframes_t nFramesPerTick = getFramesPerTick(g_dTempo); - jack_nframes_t nFrames = 0; // Frames to position - /* - if(g_pTimebase) - { - for(size_t nIndex = 0; nIndex < g_pTimebase->getEventQuant(); ++nIndex) - { - TimebaseEvent* pEvent = g_pTimebase->getEvent(nIndex); - if(pEvent->bar > bar || pEvent->bar == bar && pEvent->clock > (g_nTicksPerBeat * beat + tick) / g_nTicksPerBeat / PPQN) - break; // Ignore events later than new position - nTicksToEvent = pEvent->bar * nTicksPerBar + pEvent->clock * g_nTicksPerBeat / PPQN; - uint32_t nTicksInBlock = nTicksToEvent - nTicksToPrev; - nFrames += nFramesPerTick * nTicksInBlock; - nTicksToPrev = nTicksToEvent; - if(pEvent->type == TIMEBASE_TYPE_TEMPO) - nFramesPerTick = getFramesPerTick(pEvent->value); - else if(pEvent->type == TIMEBASE_TYPE_TIMESIG) - nTicksPerBar = g_nTicksPerBeat * (pEvent->value >> 8); +uint8_t getChannel(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + Track* pTrack = pSequence->getTrack(track); + if (pTrack) { + return pTrack->getChannel(); } } - */ - nFrames += nFramesPerTick * (bar * nTicksPerBar + beat * g_nTicksPerBeat + tick - nTicksToPrev); - return nFrames; + return 0xFF; } -bool transportRequestTimebase() { - if (jack_set_timebase_callback(g_pJackClient, 0, onJackTimebase, NULL)) - return false; - return true; +void solo(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, bool solo) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + Track* pTrack = pSequence->getTrack(track); + if (pTrack) { + pTrack->solo(); + } + } } -void transportReleaseTimebase() { jack_release_timebase(g_pJackClient); } +bool isSolo(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track) { + Sequence* pSequence = g_seqMan.getSequence(scene, phrase, sequence); + if (pSequence) { + Track* pTrack = pSequence->getTrack(track); + if (pTrack) { + return pTrack->isSolo(); + } + } + return false; +} -void transportStart(const char* client) { - bool bPlaying = (g_setTransportClient.size() != 0); - g_bClientPlaying = true; - g_setTransportClient.emplace(client); - if (bPlaying) - return; +// ** Transport management **/ +uint8_t getTransportState() { + return g_nTransportState; +} - jack_position_t pos; - if (jack_transport_query(g_pJackClient, &pos) != JackTransportRolling) - jack_transport_start(g_pJackClient); - if (g_nClockSource & TRANSPORT_CLOCK_INTERNAL) { - // Send MIDI start message - jack_nframes_t nClockTime = jack_last_frame_time(g_pJackClient); - while (g_bMutex) - std::this_thread::sleep_for(std::chrono::microseconds(10)); - g_bMutex = true; - g_mSchedule.insert(std::pair(nClockTime, new MIDI_MESSAGE({MIDI_START, 0, 0}))); - g_bMutex = false; - } +void transportStart(uint8_t id) { + if (g_nTransportState != PLAYING) + g_nTransportState = STARTING; + g_nTransportClients |= (1 << id); } -void transportStop(const char* client) { - if (strcmp(client, "ALL") == 0) - g_setTransportClient.clear(); - else if (!g_bClientPlaying) - return; +void transportStop(uint8_t id) { + if (id == 255) + g_nTransportClients = 0; else { - auto itClient = g_setTransportClient.find(std::string(client)); - if (itClient != g_setTransportClient.end()) - g_setTransportClient.erase(itClient); - } - g_bClientPlaying = (g_setTransportClient.size() != 0); - if (g_bClientPlaying) - return; - jack_transport_stop(g_pJackClient); - if (g_nClockSource & TRANSPORT_CLOCK_INTERNAL) { - // Send MIDI stop message - jack_nframes_t nClockTime = jack_last_frame_time(g_pJackClient); - while (g_bMutex) - std::this_thread::sleep_for(std::chrono::microseconds(10)); - g_bMutex = true; - g_mSchedule.insert(std::pair(nClockTime, new MIDI_MESSAGE({MIDI_STOP, 0, 0}))); - g_bMutex = false; + g_nTransportClients &= ~(1 << id); } + if ((g_nTransportClients == 0) && (g_nTransportState != STOPPED)) + g_nTransportState = STOPPING; } -void transportToggle(const char* client) { - if (transportGetPlayStatus() == JackTransportRolling) - transportStop(client); +void transportToggle(uint8_t id) { + if (g_nTransportState != STOPPED) + transportStop(id); else - transportStart(client); -} - -uint8_t transportGetPlayStatus() { - jack_position_t position; // Not used but required to query transport - jack_transport_state_t nState; - return jack_transport_query(g_pJackClient, &position); + transportStart(id); } void setTempo(double tempo) { if (tempo >= 10.0 && tempo < 500.0) { g_dTempo = tempo; - if (transportGetPlayStatus() != JackTransportRolling) - transportLocate(0); // Cludge to update transport tempo when transport not running - g_nFramesPerClock = getFramesPerClock(g_dTempo); + updateClockTiming(); + g_seqMan.setTempo(tempo); + //DPRINTF("Tempo set to: %f FramesPerClock: %u\n", g_dTempo, g_dFramesPerTick); } } -double getTempo() { return g_dTempo; } +double getTempo() { + return g_dTempo; +} -void setBeatsPerBar(uint32_t beats) { - if (beats > 0) +void setBpb(uint8_t beats) { + //!@todo This should happen at bar boundary + if (beats > 0) { g_nBeatsPerBar = beats; + } } -uint32_t getBeatsPerBar() { return g_nBeatsPerBar; } +uint8_t getBpb() { + return g_nBeatsPerBar; +} -void transportSetSyncTimeout(uint32_t timeout) { jack_set_sync_timeout(g_pJackClient, timeout); } +void setDefaultBpb(uint8_t beats) { + if (beats > 0) { + g_nDefaultBpb = beats; + g_seqMan.setDefaultTimeSig(beats); + } +} -void enableMetronome(bool enable) { - g_bMetronome = enable; - g_nMetronomePtr = -1; +uint8_t getDefaultBpb() { return g_nDefaultBpb; } + +void setMetronomeMode(uint8_t mode) { + if (mode >= METRO_MODE_LAST) + return; + g_nMetronomeMode = mode; + if (mode >= METRO_MODE_ON) + transportStart(TRANSPORT_CLIENT_METRO); + else + transportStop(TRANSPORT_CLIENT_METRO); } -bool isMetronomeEnabled() { return g_bMetronome; } +uint8_t getMetronomeMode() { + return g_nMetronomeMode; +} void setMetronomeVolume(float level) { if (level > 1.0) @@ -2520,36 +2998,120 @@ void setMetronomeVolume(float level) { float getMetronomeVolume() { return g_fMetronomeLevel; } -uint8_t getClockSource() { return g_nClockSource; } +uint8_t getExtClockPPQN() { + return g_nExtClockPPQN; +} -void setClockSource(uint8_t source) { - if (source == 0) - return; - // Restrict to allowed values - if (g_nClockSource & TRANSPORT_CLOCK_MIDI) source = TRANSPORT_CLOCK_MIDI; - else source |= TRANSPORT_CLOCK_INTERNAL; - // If TRANSPORT_CLOCK_MIDI bit has changed => Reset clock - bool resetClock = false; - if ((g_nClockSource ^ source) & TRANSPORT_CLOCK_MIDI) resetClock = true; - // Assign new clock source - g_nClockSource = source; - if (resetClock) { - // Set PPQN - if (g_nClockSource & TRANSPORT_CLOCK_MIDI) setPPQN(PPQN_MIDI); - else setPPQN(PPQN_INTERNAL); - // Reset Clock Queue - std::queue> qEmpty; - while (g_bMutex) - std::this_thread::sleep_for(std::chrono::microseconds(10)); - g_bMutex = true; - std::swap(g_qClockPos, qEmpty); - g_bMutex = false; - } -} - -uint8_t getAnalogClocksBeat() { return g_nAnalogClocksBeat; } - -void setAnalogClocksBeat(uint8_t analog_clock_divisor) { - if (analog_clock_divisor > 0) g_nAnalogClocksBeat = analog_clock_divisor; - else g_nAnalogClocksBeat = 1; +void setExtClockPPQN(uint8_t ppqn) { + //!@todo Allow pulse per bar - ppqn may be fractional + if (ppqn > 0) + g_nExtClockPPQN = ppqn; + else + g_nExtClockPPQN = 1; +} + +#include +#include + +void tapTempo() { + using Clock = std::chrono::steady_clock; + static Clock::time_point lastCallTime; + static bool hasLast = false; + static std::deque intervals; // seconds between calls + auto now = Clock::now(); + + // Timeout: reset if last call was more than 1 second ago + if (hasLast) { + std::chrono::duration sinceLast = now - lastCallTime; + if (sinceLast.count() > 1.0) { + intervals.clear(); + hasLast = false; + } + } + if (hasLast) { + std::chrono::duration diff = now - lastCallTime; + double seconds = diff.count(); + if (seconds > 0.0) { + intervals.push_back(seconds); + if (intervals.size() > 4) + intervals.pop_front(); + } + } + lastCallTime = now; + hasLast = true; + if (intervals.empty()) + return; // Not enough recent taps + + // Average interval + double sum = 0.0; + for (double s : intervals) + sum += s; + double averageInterval = sum / intervals.size(); + setTempo(60.0 / averageInterval); +} + +void enableChannel(uint8_t channel, bool enable) { + g_seqMan.enableChannel(channel, enable); +} + +bool isChannelEnabled(uint8_t channel) { + return g_seqMan.isChannelEnabled(channel); +} + +/* Phrase management */ + +uint8_t getNumPhrases(uint8_t scene) { + return g_seqMan.getNumPhrases(scene); +} + +void insertPhrase(uint8_t scene, uint8_t phrase) +{ + while (g_bMutex) + std::this_thread::sleep_for(std::chrono::microseconds(10)); + g_bMutex = true; + g_seqMan.insertPhrase(scene, phrase); + g_bMutex = false; + g_bDirty = true; +} + +void duplicatePhrase(uint8_t scene, uint8_t phrase) +{ + while (g_bMutex) + std::this_thread::sleep_for(std::chrono::microseconds(10)); + g_bMutex = true; + g_seqMan.duplicatePhrase(scene, phrase); + g_bMutex = false; + g_bDirty = true; +} + +void removePhrase(uint8_t scene, uint8_t phrase) { + while (g_bMutex) + std::this_thread::sleep_for(std::chrono::microseconds(10)); + g_bMutex = true; + stop(); //!@todo Blunt stop everything to avoid pointers to events in deleted sequences segfault! + g_seqMan.removePhrase(scene, phrase); + g_bMutex = false; + g_bDirty = true; +} + +void swapPhrase(uint8_t scene, uint8_t phrase1, uint8_t phrase2) { + while (g_bMutex) + std::this_thread::sleep_for(std::chrono::microseconds(10)); + g_bMutex = true; + g_seqMan.swapPhrase(scene, phrase1, phrase2); + g_bMutex = false; + g_bDirty = true; +} + +void setPhraseBPB(uint8_t scene, uint8_t phrase, uint8_t bpb) { + while (g_bMutex) + std::this_thread::sleep_for(std::chrono::microseconds(10)); + g_bMutex = true; + g_seqMan.setPhraseTimeSig(scene, phrase, bpb); + g_bMutex = false; + g_bDirty = true; +} + +uint8_t getPhraseBPB(uint8_t scene, uint8_t phrase) { + return g_seqMan.getPhraseTimeSig(scene, phrase); } diff --git a/zynlibs/zynseq/zynseq.h b/zynlibs/zynseq/zynseq.h index 2eca3c490..374b48abd 100644 --- a/zynlibs/zynseq/zynseq.h +++ b/zynlibs/zynseq/zynseq.h @@ -4,7 +4,7 @@ * * Library providing step sequencer as a Jack connected device * - * Copyright (C) 2020-2023 Brian Walton + * Copyright (C) 2020-2025 Brian Walton * * ****************************************************************** * @@ -21,13 +21,13 @@ * For a full copy of the GNU General Public License see the LICENSE.txt file. * * ****************************************************************** - */ +*/ /* This file declares the library interface. Only _public_ methods are exposed here. Pattern operations apply the currently selected pattern. Selecting a pattern that does not exist will create it. Empty patterns do not get saved to file. - Sequence operations act on the sequence indexed by the request. + Sequence operations act on the Index of sequenceed by the request. Acting on a sequence that does not exist will create it. The methods exposed here provide a simplified interface to the hierchical step sequencer classes. Those modules are: @@ -41,14 +41,17 @@ Organises patterns into relative time Sequence: A collection of tracks which will play synchronously - Bank: + Phrase: A collection of sequences + Scene: + A collection of phrases */ +#include + #include "constants.h" #include "pattern.h" #include "timebase.h" -#include //----------------------------------------------------------------------------- // Library Initialization @@ -57,56 +60,83 @@ extern "C" { #endif -enum TRANSPORT_CLOCK { - TRANSPORT_CLOCK_INTERNAL = 1, - TRANSPORT_CLOCK_MIDI = 2, - TRANSPORT_CLOCK_ANALOG = 4 -}; - // ** Library management functions ** /** @brief Initialise library and connect to jackd server - * @param name Client name - * @note Call init() before any other functions will work - */ + @param name Client name + @note Call init() before any other functions will work +*/ void init(char* name); +/** @brief Get the constant pulses per quarter note resolution + @retval uint32_t Quantity of pulses in each quater note +*/ +uint32_t getPPQN() { return PPQN_INTERNAL; }; + /** @brief Check if any changes have occured since last save - * @retval bool True if changed since last save - */ + @retval bool True if changed since last save +*/ bool isModified(); /** @brief Enable debug output - * @param bEnable True to enable debug output - */ + @param bEnable True to enable debug output +*/ void enableDebug(bool bEnable); +/** @brief Reset to a default state with 17 x 8 single pattern sequences +*/ +void reset(); + +/** @brief Convert binary sequence file to json + @param filename Full path and filename + @retval const char* State as json string +*/ +const char* convertToJson(const char* filename); + +/** @brief Get sequences and patterns + @retval state State as json string +*/ +const char* getState(); + +/** @brief Free buffer used to transfer state + @note Should be called after getState +*/ +void freeState(); + +/** @brief Set state of a pattern + @param id Pattern index + @param patn_state Pattern state as json string +*/ +void setPattern(uint32_t id, const char* patn_state); + +/** @brief Set sequences and patterns + @param state State as json string + @retval bool True on success + @note Pass empty state to clear sequences (returns false) +*/ +bool setState(const char* state); + /** @brief Load sequences and patterns from file - * @param filename Full path and filename - * @retval bool True on success - * @note Pass invalid or empty filename to clear sequences (returns false) - */ + @param filename Full path and filename + @retval bool True on success + @note Pass invalid or empty filename to clear sequences (returns false) +*/ bool load(const char* filename); -/** @brief Load pattern from file - * @param nPattern Pattern number - * @param filename Full path and filename - */ -bool load_pattern(uint32_t nPattern, const char* filename); +/** @brief Convert a legacy pattern from binary file + @param nPattern Pattern number + @param filename Full path and filename + @retval char* JSON representation of pattern as c-string or null on error +*/ +const char* convertPattern(uint32_t nPattern, const char* filename); /** @brief Save sequences and patterns to file - * @param filename Full path and filename - */ + @param filename Full path and filename +*/ void save(const char* filename); -/** @brief Save pattern to file - * @param nPattern Pattern number - * @param filename Full path and filename - */ -void save_pattern(uint32_t nPattern, const char* filename); - /** @brief Store current pattern on undo queue - */ +*/ void savePatternSnapshot(); /** Clear pattern undo queue */ @@ -130,1039 +160,1292 @@ void setPatternZoom(int16_t zoom); /** Set pattern zoom */ int16_t getPatternZoom(); -// ** This is not user by Pattern editor anymore. Is this used by arranger? ** +// ** This is not user by Pattern editor anymore. Is this used by arranger? - YES! But should be factored out** /** @brief Get vertical zoom - * @retval uint16_t Vertical zoom - */ + @retval uint16_t Vertical zoom +*/ uint16_t getVerticalZoom(); /** @brief Set vertical zoom - * @param zoom Vertical zoom - */ + @param zoom Vertical zoom +*/ void setVerticalZoom(uint16_t zoom); /** @brief Get horizontal zoom - * @retval uint16_t Horizontal zoom - */ + @retval uint16_t Horizontal zoom +*/ uint16_t getHorizontalZoom(); /** @brief Set horizontal zoom - * @param uint16_t Horizontal zoom - */ + @param uint16_t Horizontal zoom +*/ void setHorizontalZoom(uint16_t zoom); // ** Direct MIDI interface ** + //!@todo Should direct MIDI output be removed because JACK clients can do that themselves? /** @brief Play a note - * @param note MIDI note number - * @param velocity MIDI velocity - * @param channel MIDI channel - * @param duration Duration of note in milliseconds (0 to send note on only) Maximum 1 minute - */ + @param note MIDI note number + @param velocity MIDI velocity + @param channel MIDI channel + @param duration Duration of note in milliseconds (0 to send note on only) Maximum 1 minute +*/ void playNote(uint8_t note, uint8_t velocity, uint8_t channel, uint32_t duration = 0); -/** @brief Send MIDI START message - */ -void sendMidiStart(); - -/** @brief Send MIDI STOP message - */ -void sendMidiStop(); - -/** @brief Send MIDI CONTINUE message - */ -void sendMidiContinue(); - -/** @brief Send MIDI song position message - */ -void sendMidiSongPos(uint16_t pos); - -/** @brief Send MIDI song select message - */ -void sendMidiSong(uint32_t pos); - -/** @brief Send MIDI CLOCK message - */ -void sendMidiClock(); - /** @brief Send MIDI command - * @param status Status byte - * @param value1 Value 1 byte - * @param value2 Value 2 byte - */ + @param status Status byte + @param value1 Value 1 byte + @param value2 Value 2 byte +*/ void sendMidiCommand(uint8_t status, uint8_t value1, uint8_t value2); -/** @brief Return MIDI clock output flag - */ -uint8_t getMidiClockOutput(); - -/** @brief Enable or disable sending MIDI clock to output - * @param enable True to enable MIDI clock output (Default: true) - */ -void setMidiClockOutput(bool enable = true); - -/** @brief Get MIDI device used for external trigger of sequences - * @retval uint8_t MIDI device index - */ -uint8_t getTriggerDevice(); - -/** @brief Set MIDI device used for external trigger of sequences - * @param idev MIDI device index [0..15 or other value to disable MIDI trigger] - */ -void setTriggerDevice(uint8_t idev); - -/** @brief Get MIDI channel used for external trigger of sequences - * @retval uint8_t MIDI channel - */ -uint8_t getTriggerChannel(); - -/** @brief Set MIDI channel used for external trigger of sequences - * @param channel MIDI channel [0..15 or other value to disable MIDI trigger] - */ -void setTriggerChannel(uint8_t channel); - -/** @brief Get MIDI note number used to trigger sequence - * @param bank Index of bank containing sequence - * @param sequence Index (sequence) of sequence within bank - * @retval uint8_t MIDI note number [0xFF for none] - */ -uint8_t getTriggerNote(uint8_t bank, uint8_t sequence); - -/** @brief Set MIDI note number used to trigger sequence - * @param bank Index of bank containing sequence - * @param sequence Index (sequence) of sequence within bank - * @param note MIDI note number [0xFF for none] - */ -void setTriggerNote(uint8_t bank, uint8_t sequence, uint8_t note); - -/** @brief Get the sequence triggered by a MIDI note - * @param note MIDI note number - * @retval uint16_t Bank and sequence id encoded as 16-bit - */ -uint16_t getTriggerSequence(uint8_t note); - // ** Pattern management functions - pattern events are quantized to steps ** //!@todo Current implementation selects a pattern then operates on it. API may be simpler to comprehend if patterns were acted on directly by passing the //! pattern index, e.g. clearPattern(index) -/** @brief Enable record from MIDI input to add notes to current pattern - * @param enable True to enable MIDI input - */ -void enableMidiRecord(bool enable); - -/** @brief Get MIDI record enable state - * @retval bool True if MIDI record enabled - */ -bool isMidiRecord(); - /** @brief Create a new pattern - * @retval uint32_t Index of new pattern - */ + @retval uint32_t Index of new pattern +*/ uint32_t createPattern(); /** @brief Get quantity of patterns in a track - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - * @retval uint32_t quantity of patterns in track - */ -uint32_t getPatternsInTrack(uint8_t bank, uint8_t sequence, uint32_t track); - -/** @brief Get index of pattern within a track - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - * @param position Quantity of clock cycles from start of sequence where pattern starts - * @retval uint32_t Pattern index or -1 if not found - */ -uint32_t getPattern(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position); - -/** @brief Get index of pattern within a track - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - * @param position Quantity of clock cycles from start of sequence that pattern spans - * @retval uint32_t Pattern index or -1 if not found - */ -uint32_t getPatternAt(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position); - -/** @brief Select active pattern - * @note All subsequent pattern methods act on this pattern - * @note Pattern is created if it does not exist - * @param pattern Index of pattern to select - */ -void selectPattern(uint32_t pattern); - -/** @brief Check if selected pattern is empty - * @param pattern Pattern index - * @retval bool True if pattern is empty - */ -bool isPatternEmpty(uint32_t pattern); - -/** @brief Get the index of the selected pattern - * @retval uint32_t Index of pattern or -1 if not found - */ -uint32_t getPatternIndex(); - -/** @brief Get quantity of steps in selected pattern - * @retval uint32_t Quantity of steps - */ -uint32_t getSteps(); - -/** @brief Get quantity of beats in selected pattern - * @retval uint32_t Quantity of beats - */ -uint32_t getBeatsInPattern(); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence of sequence + @param track Index of track + @retval uint32_t quantity of patterns in track +*/ +uint32_t getPatternsInTrack(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track); + +/** @brief Get index of pattern within a track starting at position + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence of sequence + @param track Index of track + @param position Quantity of clock cycles from start of sequence where pattern starts + @retval uint32_t Pattern index or -1 if not found +*/ +uint32_t getPattern(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint32_t position); + +/** @brief Get index of pattern within a track spanning position + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence of sequence + @param track Index of track + @param position Quantity of clock cycles from start of sequence that pattern spans + @retval uint32_t Pattern index or -1 if not found +*/ +uint32_t getPatternAt(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint32_t position); -/** @brief Set quantity of beats in selected pattern - * @param beats Quantity of beats - * @note Adjusts steps to match steps per beat - */ -void setBeatsInPattern(uint32_t beats); +/** @brief Copy pattern + @param source Index of pattern from which to copy + @param destination Index of pattern to which to copy +*/ +void copyPattern(uint32_t source, uint32_t destination); -/** @brief Get pattern length in clock cycles - * @param pattern Index of pattern - * returns Length in clock cycles - */ -uint32_t getPatternLength(uint32_t pattern); +/** @brief Paste (merge) copy/paste buffer into the specified pattern + @param pattern Index of pattern + @param dstep Quantity of steps to offset + @param doffset Fractional time offset + @param dnote Note offset + @param truncate False to use circular horizontal overflow. True to skip events out of step range. +*/ +void pastePatternBuffer(uint32_t pattern, int32_t dstep, float doffset, int8_t dnote, bool truncate=false); + +/** @brief Copy/Cut a selection from the specified pattern to the copy/paste buffer + @param pattern Index of pattern + @param step1 step-range start + @param step2 step-range end + @param note1 note-range start + @param note2 note-range end + @param cut True tp delete events from source pattern + @retval uint32_t Number of events copied (or cutted!) +*/ +uint32_t copyPatternBuffer(uint32_t pattern, uint32_t step1=0, uint32_t step2=0xFFFFFFFF, uint8_t note1=0, uint8_t note2=127, bool cut=false); + +/** @brief Get indexes of note events from a specified pattern in the specified step & note range. + @param pattern Index of pattern + @param ev_indexes pointer to integer array. It will be filled with the list of event indexes + @param limit size of integer array (ev_indexes) + @param step1 step-range start + @param step2 step-range end + @param note1 note-range start + @param note2 note-range end + @retval uint32_t the number of event indexes copied into the array ev_indexes. +*/ +uint32_t getPatternSelectionIndexes(uint32_t pattern, uint32_t* ev_indexes, uint32_t limit, uint32_t step1=0, uint32_t step2=0xFFFFFFFF, uint8_t note1=0, uint8_t note2=127); -/** @brief Get clocks per step for selected pattern - * @retval uint32_t Clock cycles per step - */ -uint32_t getClocksPerStep(); -/** @brief Get steps per beat from selected pattern - * @retval uint32_t Steps per beat - */ -uint32_t getStepsPerBeat(); +// ** Functions acting on the globally selected pattern ** -/** @brief Set steps per beat - * @param steps Steps per beat [1,2,3,4,6,8,12,24] - * @note Calculates pattern length from beats in pattern - */ -void setStepsPerBeat(uint32_t steps); - -/** @brief Get swing division from selected pattern - * @retval uint32_t swing division - */ -uint32_t getSwingDiv(); +/** @brief Select active pattern + @note All subsequent pattern methods act on this pattern + @note Pattern is created if it does not exist + @param pattern Index of pattern to select +*/ +void selectPattern(uint32_t pattern); -/** @brief Set swing division in selected pattern - * @param swing division, from 1 to pattern's StepsPerBeat - */ -void setSwingDiv(uint32_t div); +/** @brief Get the index of the selected pattern + @retval uint32_t Index of pattern or -1 if not found +*/ +uint32_t getPatternIndex(); -/** @brief Get swing amount from selected pattern - * @retval float swing division - */ -float getSwingAmount(); +/** @brief Enable record from MIDI input to add notes to current pattern + @param enable True to enable MIDI input +*/ +void enableMidiRecord(bool enable); -/** @brief Set swing amount in selected pattern - * @param swing amount, from 0 to 1 (0.33 is perfect-triplet swing, >0.5 is not really swing) - */ -void setSwingAmount(float amount); +/** @brief Get MIDI record enable state + @retval bool True if MIDI record enabled +*/ +bool isMidiRecord(); -/** @brief Get Time Humanization amount from selected pattern - * @retval float - */ -float getHumanTime(); +/** @brief Add note to selected pattern + @param step Index of step at which to add note + @param note MIDI note number + @param velocity MIDI velocity value + @param duration Quantity of steps note should play for + @param offset Offset factor of start of step + @retval bool True on success +*/ +bool addNote(uint32_t step, uint8_t note, uint8_t velocity, float duration, float offset = 0.0); -/** @brief Set Time Humanization amount in selected pattern - * @param amount, from 0 to FLOAT_MAX - */ -void setHumanTime(float amount); +/** @brief Removes note from selected pattern + @param step Index of step at which to remove note + @param note MIDI note number to remove +*/ +void removeNote(uint32_t step, uint8_t note); -/** @brief Get Velocity Humanization amount from selected pattern - * @retval float - */ -float getHumanVelo(); +/** @brief Remove all note events from pattern +*/ +void clearNotes(); -/** @brief Set Velocity Humanization amount in selected pattern - * @param amount, from 0 to FLOAT_MAX - */ -void setHumanVelo(float amount); +/** @brief Get data of pattern event at specified index + @param index Event index + @param data pointer to a struct to contain event data + @retval int32_t Index of the event. -1 if index is out of range. +*/ +int32_t getEventDataAt(uint32_t index, StepEvent* data); -/** @brief Get PlayChance from selected pattern - * @retval float - */ -float getPlayChance(); +/** @brief Get data of copy/paste buffer event at specified index + @param index Event index + @param data pointer to a struct to contain event data + @retval int32_t Index of the event. -1 if index is out of range. +*/ +int32_t getBufferEventDataAt(uint32_t index, StepEvent* data); -/** @brief Set PlayChance in selected pattern - * @param chance, probability of playing notes - */ -void setPlayChance(float chance); +/** @brief Get index of specified note + @param position Quantity of steps from start of pattern at which to check for note + @param note MIDI note number + @retval int32_t Index of the note event in the events vector +*/ +int32_t getNoteIndex(uint32_t step, uint8_t note); -/** @brief Add note to selected pattern - * @param step Index of step at which to add note - * @param note MIDI note number - * @param velocity MIDI velocity value - * @param duration Quantity of steps note should play for - * @param offset Offset factor of start of step - * @retval bool True on success - */ -bool addNote(uint32_t step, uint8_t note, uint8_t velocity, float duration, float offset = 0.0); +/** @brief Get data of specified note + @param position Quantity of steps from start of pattern at which to check for note + @param note MIDI note number + @param data pointer to a struct to contain event data + @retval int32_t Index of the note event in the events vector +*/ +int32_t getNoteData(uint32_t step, uint8_t note, StepEvent* data, bool cp_buffer=false); -/** @brief Removes note from selected pattern - * @param step Index of step at which to remove note - * @param note MIDI note number to remove - */ -void removeNote(uint32_t step, uint8_t note); +/** @brief Set data of a specified note, excluding position, offset, command and note number (nValue1Start) + @param position Quantity of steps from start of pattern at which to check for note + @param note MIDI note number + @param data pointer to a struct to contain event data + @retval int32_t Index of the note event in the events vector +*/ +int32_t setNoteData(uint32_t step, uint8_t note, StepEvent* data); /** @brief Get step that note starts - * @param position Quantity of steps from start of pattern at which to check for note - * @param note MIDI note number - * @retval int32_t Quantity of steps from start of pattern that note starts or -1 if note not found - */ + @param position Quantity of steps from start of pattern at which to check for note + @param note MIDI note number + @retval int32_t Quantity of steps from start of pattern that note starts or -1 if note not found +*/ int32_t getNoteStart(uint32_t step, uint8_t note); /** @brief Get velocity of note in selected pattern - * @param step Index of step at which note resides - * @param note MIDI note number - * @retval uint8_t Note velocity (0..127) - */ + @param step Index of step at which note resides + @param note MIDI note number + @retval uint8_t Note velocity (0..127) +*/ uint8_t getNoteVelocity(uint32_t step, uint8_t note); /** @brief Set velocity of note in selected pattern - * @param step Index of step at which note resides - * @param note MIDI note number - * @param velocity MIDI velocity - */ + @param step Index of step at which note resides + @param note MIDI note number + @param velocity MIDI velocity +*/ void setNoteVelocity(uint32_t step, uint8_t note, uint8_t velocity); /** @brief Get offset of note in selected pattern - * @param step Index of step at which note resides - * @param note MIDI note number - * @retval float offset Step fraction, from 0.0 to 1.0 - */ + @param step Index of step at which note resides + @param note MIDI note number + @retval float offset Step fraction, from 0.0 to 1.0 +*/ float getNoteOffset(uint32_t step, uint8_t note); /** @brief Set offset of note in selected pattern - * @param step Index of step at which note resides - * @param note MIDI note number - * @param offset Step fraction, from 0.0 to 1.0 - */ + @param step Index of step at which note resides + @param note MIDI note number + @param offset Step fraction, from 0.0 to 1.0 +*/ void setNoteOffset(uint32_t step, uint8_t note, float offset); /** @brief Add control to selected pattern - * @param step Index of step at which to add control - * @param control MIDI control number - * @param valueStart MIDI control value at start of event - * @param valueEnd MIDI control value at end of event - * @param duration Quantity of steps control should span (iterate values) - * @param offset Offset factor of start of step - * @retval bool True on success - */ + @param step Index of step at which to add control + @param control MIDI control number + @param valueStart MIDI control value at start of event + @param valueEnd MIDI control value at end of event + @param duration Quantity of steps control should span (iterate values) + @param offset Offset factor of start of step + @retval bool True on success +*/ bool addControl(uint32_t step, uint8_t control, uint8_t valueStart, uint8_t valueEnd, float duration, float offset = 0.0); - /** @brief Removes control from selected pattern - * @param step Index of step at which to remove control - * @param control MIDI control number to remove - */ +/** @brief Removes a control step from selected pattern + @param step Index of step at which to remove control + @param control MIDI control number to remove +*/ void removeControl(uint32_t step, uint8_t control); - - /** @brief Get step that control starts - * @param position Quantity of steps from start of pattern at which to check for control - * @param control MIDI control number - * @retval int32_t Quantity of steps from start of pattern that control starts or -1 if note not found - */ -int32_t getControlStart(uint32_t step, uint8_t control); + +/** @brief Clean all control steps from selected pattern + @param control MIDI control number to remove +*/ +void clearControl(uint8_t control); + +/** @brief Get step that control starts + @param position Quantity of steps from start of pattern at which to check for control + @param control MIDI control number + @retval int32_t Quantity of steps from start of pattern that control starts or -1 if note not found +*/ +int32_t getControlStart(uint32_t step, uint8_t control); /** @brief Get duration of control in selected pattern - * @param position Index of step at which control starts - * @note MIDI control number - * @retval float Duration in steps or 0.0 if control does not exist - */ - float getControlDuration(uint32_t step, uint8_t control); + @param position Index of step at which control starts + @note MIDI control number + @retval float Duration in steps or 0.0 if control does not exist +*/ +float getControlDuration(uint32_t step, uint8_t control); /** @brief Get value of control in selected pattern - * @param step Index of step at which control resides - * @param control MIDI control number - * @retval uint8_t control value (0..127) - */ - uint8_t getControlValue(uint32_t step, uint8_t control); - - /** @brief Set value of control in selected pattern - * @param step Index of step at which control resides - * @param control MIDI control number - * @param valueStart MIDI value at start of event - * @param valueEnd MIDI value at end of event - */ - void setControlValue(uint32_t step, uint8_t control, uint8_t valueStart, uint8_t valueEnd); + @param step Index of step at which control resides + @param control MIDI control number + @retval uint8_t control value (0..127) +*/ +uint8_t getControlValue(uint32_t step, uint8_t control); + +/** @brief Get end value of control in selected pattern + @param step Index of step at which control resides + @param control MIDI control number + @retval uint8_t end control value (0..127) +*/ +uint8_t getControlValueEnd(uint32_t step, uint8_t control); + +/** @brief Set value of control in selected pattern + @param step Index of step at which control resides + @param control MIDI control number + @param valueStart MIDI value at start of event + @param valueEnd MIDI value at end of event +*/ +void setControlValue(uint32_t step, uint8_t control, uint8_t valueStart, uint8_t valueEnd); /** @brief Get offset of control in selected pattern - * @param step Index of step at which control resides - * @param control MIDI control number - * @retval float offset Step fraction, from 0.0 to 1.0 - */ - float getControlOffset(uint32_t step, uint8_t control); - - /** @brief Set offset of control in selected pattern - * @param step Index of step at which control resides - * @param control MIDI control number - * @param offset Step fraction, from 0.0 to 1.0 - */ - void setControlOffset(uint32_t step, uint8_t control, float offset); - -/** @brief Get stutter count of note in selected pattern - * @param step Index of step at which note resides - * @param note MIDI note number - * @retval uint8_t Stutter count - */ -uint8_t getStutterCount(uint32_t step, uint8_t note); - -/** @brief Set stutter count of note in selected pattern - * @param step Index of step at which note resides - * @param note MIDI note number - * @param count Stutter count - */ -void setStutterCount(uint32_t step, uint8_t note, uint8_t count); - -/** @brief Get stutter duration of note in selected pattern - * @param step Index of step at which note resides - * @param note MIDI note number - * @retval uint8_t Stutter duration in clock cycles - */ -uint8_t getStutterDur(uint32_t step, uint8_t note); - -/** @brief Set stutter duration of note in selected pattern - * @param step Index of step at which note resides - * @param note MIDI note number - * @param dur Stutter duration in clock cycles - */ -void setStutterDur(uint32_t step, uint8_t note, uint8_t dur); + @param step Index of step at which control resides + @param control MIDI control number + @retval float offset Step fraction, from 0.0 to 1.0 +*/ +float getControlOffset(uint32_t step, uint8_t control); + +/** @brief Set offset of control in selected pattern + @param step Index of step at which control resides + @param control MIDI control number + @param offset Step fraction, from 0.0 to 1.0 +*/ +void setControlOffset(uint32_t step, uint8_t control, float offset); + +/** @brief Set stutter parameters of note in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @param speed Stutter speed + @param velfx Stutter velocity FX value (0=none, 1=fadeIn, 2=fadeOut) + @param ramp Stutter speed ramp value (0=None, 1=up, 2=down) +*/ +void setNoteStutter(uint32_t step, uint8_t note, uint8_t speed, uint8_t velfx, uint8_t ramp); + +/** @brief Get stutter speed of note in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @retval uint8_t Stutter speed +*/ +uint8_t getNoteStutterSpeed(uint32_t step, uint8_t note); + +/** @brief Set stutter speed of note in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @param speed Stutter speed +*/ +void setNoteStutterSpeed(uint32_t step, uint8_t note, uint8_t speed); + +/** @brief Get stutter velocity FX of note in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @retval uint8_t Stutter velocity FX value (0=none, 1=fadeIn, 2=fadeOut) +*/ +uint8_t getNoteStutterVelfx(uint32_t step, uint8_t note); + +/** @brief Set stutter velocity FX value of note in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @param velfx Stutter velocity FX value (0=none, 1=fadeIn, 2=fadeOut) +*/ +void setNoteStutterVelfx(uint32_t step, uint8_t note, uint8_t velfx); + +/** @brief Get stutter speed ramp of note in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @retval uint8_t Stutter speed ramp value (0=None, 1=up, 2=down) +*/ +uint8_t getNoteStutterRamp(uint32_t step, uint8_t note); + +/** @brief Set stutter speed ramp value of note in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @param ramp Stutter speed ramp value (0=None, 1=up, 2=down) +*/ +void setNoteStutterRamp(uint32_t step, uint8_t note, uint8_t ramp); /** @brief Get note play chance in selected pattern - * @param step Index of step at which note resides - * @param note MIDI note number - * @retval uint8_t Note play probability from 0% to 100% - */ -uint8_t getNotePlayChance(uint32_t step, uint8_t note); + @param step Index of step at which note resides + @param note MIDI note number + @retval float Note play probability (0..1 for 0%..100%) +*/ +float getNotePlayChance(uint32_t step, uint8_t note); /** @brief Set note play chance in selected pattern - * @param step Index of step at which note resides - * @param note MIDI note number - * @param chance Note play probability from 0% to 100% - */ -void setNotePlayChance(uint32_t step, uint8_t note, uint8_t chance); + @param step Index of step at which note resides + @param note MIDI note number + @param chance Note play probability (0..1 for 0%..100%) +*/ +void setNotePlayChance(uint32_t step, uint8_t note, float chance); + +/** @brief Get note play frequency in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @retval uint8_t Note play frequency: last bit => play/skip, higher bits => n loops to play/skip + Can be used for enabling/disabling the event: 0 => play never, 1 => play on every loop +*/ +uint8_t getNotePlayFreq(uint32_t step, uint8_t note); + +/** @brief Set note play frequency in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @param freq Note play frequency: last bit => play/skip, higher bits => n loops to play/skip + Can be used for enabling/disabling the event: 0 => play never, 1 => play on every loop +*/ +void setNotePlayFreq(uint32_t step, uint8_t note, uint8_t freq); + +/** @brief Get note stutter chance in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @retval float Note stutter probability (0..1 for 0%..100%) +*/ +float getNoteStutterChance(uint32_t step, uint8_t note); + +/** @brief Set note stutter chance in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @param chance Note stutter probability (0..1 for 0%..100%) +*/ +void setNoteStutterChance(uint32_t step, uint8_t note, float chance); + +/** @brief Get note stutter frequency in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @retval uint8_t Note stutter frequency: last bit => play/skip, higher bits => n loops to play/skip +*/ +uint8_t getNoteStutterFreq(uint32_t step, uint8_t note); + +/** @brief Set stutter frequency in selected pattern + @param step Index of step at which note resides + @param note MIDI note number + @param freq Note stutter frequency: last bit => play/skip, higher bits => n loops to play/skip +*/ +void setNoteStutterFreq(uint32_t step, uint8_t note, uint8_t freq); /** @brief Get duration of note in selected pattern - * @param position Index of step at which note starts - * @note MIDI note number - * @retval float Duration in steps or 0.0 if note does not exist - */ + @param position Index of step at which note starts + @note MIDI note number + @retval float Duration in steps or 0.0 if note does not exist +*/ float getNoteDuration(uint32_t step, uint8_t note); /** @brief Add programme change to selected pattern - * @param step Index of step at which to add program change - * @param program MIDI program change number - * @retval bool True on success - */ + @param step Index of step at which to add program change + @param program MIDI program change number + @retval bool True on success +*/ bool addProgramChange(uint32_t step, uint8_t program); /** @brief Removes program change from selected pattern - * @param step Index of step at which to remove program change - */ -void removeProgramChange(uint32_t step, uint8_t program); + @param step Index of step at which to remove program change +*/ +void removeProgramChange(uint32_t ); /** @brief Get program change in selected pattern - * @param step Index of step at which program change resides - * @retval uint8_t Program change (0..127, 0xFF if no program change at this step) - */ + @param step Index of step at which program change resides + @retval uint8_t Program change (0..127, 0xFF if no program change at this step) +*/ uint8_t getProgramChange(uint32_t step); /** @brief Transpose selected pattern - * @param value +/- quantity of notes to transpose - */ + @param value +/- quantity of notes to transpose +*/ void transpose(int8_t value); /** @brief Change velocity of all notes in patterm - * @param value Offset to adjust +/-127 - */ + @param value Offset to adjust +/-127 +*/ void changeVelocityAll(int value); +/** @brief Change velocity of a list of notes in pattern + @param value Offset to adjust +/-127 + @param evi_list Event index list + @param n number of events in list +*/ +void changeVelocityList(float value, uint32_t* evi_list, uint32_t n); + /** @brief Change duration of all notes in patterm - * @param value Offset to adjust +/-100.0 or whatever - */ + @param value Offset to adjust +/-100.0 or whatever +*/ void changeDurationAll(float value); -/** @brief Change stutter count of all notes in patterm - * @param value Offset to adjust +/-100 or whatever - */ -void changeStutterCountAll(int value); +/** @brief Change duration of a list of notes in pattern + @param value Offset to adjust +/-127 + @param evi_list Event index list + @param n number of events in list +*/ +void changeDurationList(float value, uint32_t* evi_list, uint32_t n); -/** @brief Change stutter duration of all notes in patterm - * @param value Offset to adjust +/-100 or whatever - */ -void changeStutterDurAll(int value); +/** @brief Flag pattern as modified - also sets flags in relevant sequences and tracks + @param pPattern Pointer to pattern + @param bModified True to set pattern modified + @param bodfiedTracks True to set tracks modified +*/ +void setPatternModified(Pattern* pPattern, bool bModified = true, bool bModifiedTracks = false); -/** @brief Clears events from selected pattern - * @note Does not change other parameters such as pattern length - */ -void clear(); +/** @brief Check if selected pattern has changed since last check + @retval bool True if pattern has changed +*/ +bool isPatternModified(); -/** @brief Copy pattern - * @param source Index of pattern from which to copy - * @param destination Index of pattern to which to copy - */ -void copyPattern(uint32_t source, uint32_t destination); +/** @brief Get position of playhead within pattern in steps + @retval uint32_t Quantity of steps from start of pattern to playhead +*/ +uint32_t getPatternPlayhead(); + +/** @brief Get quantity of steps in pattern + @retval uint32_t Quantity of steps +*/ +uint32_t getSteps(); + +/** @brief Get steps per beat from selected pattern + @retval uint32_t Steps per beat +*/ +uint32_t getStepsPerBeat(); + +/** @brief Set steps per beat + @param pattern Index of pattern + @param steps Steps per beat [1,2,3,4,6,8,12,24] + @note Calculates pattern length from beats in pattern +*/ +void setStepsPerBeat(uint32_t steps); + +/** @brief Get the last populated step + @retval uint32_t Index of last populated step or -1 if empty + @note This may allow checking for empty patterns or whether truncation will have an effect +*/ +uint32_t getLastStep(); + +/** @brief Get swing division from selected pattern + @retval uint32_t swing division +*/ +uint32_t getSwingDiv(); + +/** @brief Set swing division in selected pattern + @param swing division, from 1 to pattern's StepsPerBeat +*/ +void setSwingDiv(uint32_t div); + +/** @brief Get swing amount from selected pattern + @retval float swing division +*/ +float getSwingAmount(); + +/** @brief Set swing amount in pattern + @param swing amount, from 0 to 1 (0.33 is perfect-triplet swing, >0.5 is not really swing) +*/ +void setSwingAmount(float amount); + +/** @brief Get Time Humanization amount from pattern + @param pattern Index of pattern + @retval float +*/ +float getHumanTime(); + +/** @brief Set Time Humanization amount in pattern + @param pattern Index of pattern + @param amount, from 0 to FLOAT_MAX +*/ +void setHumanTime(float amount); + +/** @brief Get Velocity Humanization amount from pattern + @retval float +*/ +float getHumanVelo(); + +/** @brief Set Velocity Humanization amount in pattern + @param amount, from 0 to FLOAT_MAX +*/ +void setHumanVelo(float amount); + +/** @brief Get PlayChance from pattern + @retval float +*/ +float getPlayChance(); + +/** @brief Set PlayChance in pattern + @param chance, probability of playing notes +*/ +void setPlayChance(float chance); + +// ** Functions acting on specified pattern ** + +/** @brief Check if selected pattern is empty + @param pattern Pattern index + @retval bool True if pattern is empty +*/ +bool isPatternEmpty(uint32_t pattern); + +/** @brief Get quantity of beats in pattern + @param pattern Index of pattern + @retval uint32_t Quantity of beats +*/ +uint32_t getBeatsInPattern(uint32_t pattern); + +/** @brief Set quantity of beats in pattern + @param pattern Index of pattern + @param beats Quantity of beats + @note Adjusts steps to match steps per beat +*/ +void setBeatsInPattern(uint32_t pattern, uint32_t beats); + +/** @brief Get pattern length in clock cycles + @param pattern Index of pattern + @retval Length in clock cycles +*/ +uint32_t getPatternLength(uint32_t pattern); + +/** @brief Get the note at event index + @param pattern Index of pattern + @param index Index of event + @retval uint8_t MIDI note number or 0xff for invalid index +*/ +uint8_t getNoteAtIndex(uint32_t pattern, uint32_t index); + +/** @brief Get clocks per step for selected pattern + @param pattern Index of paatern + @retval uint32_t Clock cycles per step +*/ +uint32_t getClocksPerStep(uint32_t pattern); + +/** @brief Clears events from pattern + @note Does not change other parameters such as pattern length +*/ +void clearPattern(uint32_t pattern); + +// ** Functions used by pattern editor but not related to the actual pattern ** /** @brief Set note used as rest when using MIDI input for pattern editing - * @param note MIDI note number [0..127] - * @note >127 to disable rest - */ + @param note MIDI note number [0..127] + @note >127 to disable rest +*/ void setInputRest(uint8_t note); /** @brief Get note used as rest when using MIDI input for pattern editing - * @retval uint8_t MIDI note number [0..127, 0xFF if disabled] - */ + @retval uint8_t MIDI note number [0..127, 0xFF if disabled] +*/ uint8_t getInputRest(); /** @brief Set scale used by pattern editor for selected pattern - * @param scale Index of scale - */ + @param scale Index of scale +*/ void setScale(uint32_t scale); /** @brief Get scale used by pattern editor for selected pattern - * @retval uint32_t Index of scale - */ + @retval uint32_t Index of scale +*/ uint32_t getScale(); /** @brief Set scale tonic (root note) for selected pattern (used by pattern editor) - * @param tonic Scale tonic - */ + @param tonic Scale tonic +*/ void setTonic(uint8_t tonic); /** @brief Get scale tonic (root note) for selected pattern (used by pattern editor) - * @retval uint8_t Tonic - */ + @retval uint8_t Tonic +*/ uint8_t getTonic(); -/** @brief Flag pattern as modified - also sets flags in relevant sequences and tracks - */ -void setPatternModified(Pattern* pPattern, bool bModified = true, bool bModifiedTracks = false); - -/** @brief Check if selected pattern has changed since last check - * @retval bool True if pattern has changed - */ -bool isPatternModified(); - -/** @brief Get the reference note - * @retval uint8_t MIDI note number - * @note May be used for position within user interface - */ +/** @brief Get the reference note + @retval uint8_t MIDI note number + @note May be used for position within user interface +*/ uint8_t getRefNote(); -/** @brief Set the reference note - * @param MIDI note number - * @note May be used for position within user interface - */ +/** @brief Set the reference note + @param MIDI note number + @note May be used for position within user interface +*/ void setRefNote(uint8_t note); /** @brief Get the "Quantize Notes" flag - * @retval bool flag - */ -bool getQuantizeNotes(); + @retval uint8_t quantize value (0, 1, 2, 3, 4, 6, 8, 12, 16) +*/ +uint8_t getQuantizeNotes(); /** @brief Set the "Quantize Notes" flag - * @param flag - */ -void setQuantizeNotes(bool flag); - -/** @brief Get the last populated step - * @retval uint32_t Index of last populated step or -1 if empty - * @note This may allow checking for empty patterns or whether truncation will have an effect - */ -uint32_t getLastStep(); + @param quantize value (0, 1, 2, 3, 4, 6, 8, 12, 16) +*/ +void setQuantizeNotes(uint8_t qn); -/** @brief Get position of playhead within pattern in steps - * @retval uint32_t Quantity of steps from start of pattern to playhead - */ -uint32_t getPatternPlayhead(); +/** @brief Get the "Interpolate CC values" flag for a given CC number + @param ccnum + @retval bool flag +*/ +bool getInterpolateCC(uint8_t ccnum); + +/** @brief Set the "Interpolate CC" flag for a given CC number + @param ccnum + @param flag +*/ +void setInterpolateCC(uint8_t ccnum, bool flag); + +/** @brief Set "Interpolate CC" flags to default values for each CC number +*/ +void setInterpolateCCDefaults(); + +// ** Scene management functions ** + +/** @brief Select a scene + @param scene Index of scene + @retval bool True if new scene created +*/ +bool setScene(uint8_t scene); + +/** @brief Get currently selected scene + @retval uint8_t Index of scene +*/ +uint8_t getScene(); + +/** @brief Get quantity of scenes + @retval uint8_t Quantity of scenes +*/ +uint8_t getNumScenes(); + +/** @brief Remove a scene + @param scene Inde of scene + @note Subsequenct scenes are renumbered. If selected scene is higher, select scene zero. +*/ +void removeScene(uint8_t scene); // ** Track management functions ** /** @brief Add pattern to a track - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - * @param position Quantity of clock cycles from start of track at which to add pattern - * @param pattern Index of pattern - * @param force True to remove overlapping patterns, false to fail if overlapping patterns - * @retval True if pattern inserted - */ -bool addPattern(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position, uint32_t pattern, bool force); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param track Index of track + @param position Quantity of clock cycles from start of track at which to add pattern + @param pattern Index of pattern + @param force True to remove overlapping patterns, false to fail if overlapping patterns + @retval True if pattern inserted +*/ +bool addPattern(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint32_t position, uint32_t pattern, bool force); /** @brief Remove pattern from track - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - * @param position Quantity of clock cycles from start of track from which to remove pattern - */ -void removePattern(uint8_t bank, uint8_t sequence, uint32_t track, uint32_t position); - -/** @brief Removes unused empty patterns - */ -void cleanPatterns(); - -/** @brief Toggle mute of track - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - */ -void toggleMute(uint8_t bank, uint8_t sequence, uint32_t track); - -/** @brief Get track mute state - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - * @retval bool True if muted - */ -bool isMuted(uint8_t bank, uint8_t sequence, uint32_t track); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param track Index of track + @param position Quantity of clock cycles from start of track from which to remove pattern +*/ +void removePattern(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint32_t position); + +/** @brief Toggle mute of track + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param track Index of track +*/ +void toggleMute(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track); + +/** @brief Get track mute state + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param track Index of track + @retval bool True if muted +*/ +bool isMuted(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track); // ** Sequence & Track management functions ** -/** @brief Set track type - * @param bank Index of bank - * @param sequence Sequence ID - * @param track Index of track - * @param type Track type: 0 = MIDI Track, 1 = Audio, 2 = MIDI Program - */ -void setTrackType(uint8_t bank, uint8_t sequence, uint32_t track, uint8_t type); - -/** @brief Get track type - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - * @retval uint8_t Track type - */ -uint8_t getTrackType(uint8_t bank, uint8_t sequence, uint32_t track); - -/** @brief Set track's associated chain ID - * @param bank Index of bank - * @param sequence Sequence ID - * @param track Index of track - * @param chain_id Chain ID - */ -void setChainID(uint8_t bank, uint8_t sequence, uint32_t track, uint8_t chain_id); - -/** @brief Get track's associated chain ID - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - * @retval uint8_t Chain ID - */ -uint8_t getChainID(uint8_t bank, uint8_t sequence, uint32_t track); +/** @brief Set track output + @param scene Index of scene + @param phrase Index of phrase + @param sequence Sequence ID + @param track Index of track + @param output Track output: 0xfe for clippy output. 0xff for no output. +*/ +void setTrackOutput(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint8_t output); + +/** @brief Get track output + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param track Index of track + @retval uint8_t Track output: 0xfe for clippy output. 0xff for no output. +*/ +uint8_t getTrackOutput(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track); /** @brief Set track MIDI channel - * @param bank Index of bank - * @param sequence Sequence ID - * @param track Index of track - * @param channel MIDI channel - */ -void setChannel(uint8_t bank, uint8_t sequence, uint32_t track, uint8_t channel); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Sequence ID + @param track Index of track + @param channel MIDI channel +*/ +void setChannel(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track, uint8_t channel); /** @brief Get track MIDI channel - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - * @retval uint8_t MIDI channel - */ -uint8_t getChannel(uint8_t bank, uint8_t sequence, uint32_t track); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param track Index of track + @retval uint8_t MIDI channel +*/ +uint8_t getChannel(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track); /** @brief Get current play mode for a sequence - * @param bank Index of bank containing sequence - * @param sequence Index (sequence) of sequence within bank - * @retval uint8_t Play mode [DISABLED | ONESHOT | LOOP | ONESHOTALL | LOOPALL] - */ -uint8_t getPlayMode(uint8_t bank, uint8_t sequence); + @param scene Index of scene containing sequence + @param phrase Index of phrase containing sequence + @param sequence Index of sequence (sequence) of sequence within phrase + @retval uint8_t Stop mode (bits 0..1). Start mode (bit 2) modes +*/ +uint8_t getSequenceMode(uint8_t scene, uint8_t phrase, uint8_t sequence); /** @brief Set play mode of a sequence - * @param bank Index of bank containing sequence - * @param sequence Index (sequence) of sequence within bank - * @param mode Play mode [DISABLED | ONESHOT | LOOP | ONESHOTALL | LOOPALL] - */ -void setPlayMode(uint8_t bank, uint8_t sequence, uint8_t mode); + @param scene Index of scene containing sequence + @param phrase Index of phrase containing sequence + @param sequence Index of sequence (sequence) of sequence within phrase + @param mode Stop mode (bits 0..1). Start mode (bit 2) modes +*/ +void setSequenceMode(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t mode); /** @brief Get play state - * @param bank Index of bank containing sequence - * @param sequence Index (sequence) of sequence within bank - * @retval uint8_t Play state [STOPPED | PLAYING | STOPPING | STARTING | RESTARTING | STOPPING_SYNC] - */ -uint8_t getPlayState(uint8_t bank, uint8_t sequence); - -/** @brief Check if sequence is empty (all patterns have no events) - * @param bank Index of bank containing sequence - * @param sequence Index (sequence) of sequence within bank - * @retval bool True if sequence empty else false if any pattern in sequence has any events - */ -bool isEmpty(uint8_t bank, uint8_t sequence); + @param scene Index of scene containing sequence + @param phrase Index of phrase containing sequence + @param sequence Index of sequence (sequence) of sequence within phrase + @retval uint8_t Play state [STOPPED | PLAYING | STOPPING | STARTING | STOPPING_SYNC] +*/ +uint8_t getPlayState(uint8_t scene, uint8_t phrase, uint8_t sequence); + +/** @brief Check if sequence is empty (all patterns have no events) + @param scene Index of scene containing sequence + @param phrase Index of phrase containing sequence + @param sequence Index of sequence (sequence) of sequence within phrase + @retval bool True if sequence empty else false if any pattern in sequence has any events +*/ +bool isEmpty(uint8_t scene, uint8_t phrase, uint8_t sequence); /** @brief Set play state - * @param bank Index of bank containing sequence - * @param sequence Index (sequence) of sequence within bank - * @param state Play state [STOPPED | STARTING | PLAYING | STOPPING] - * @note STARTING will reset to start of sequence. PLAYING resumes at last played position. - * @note If all sequences have stopped and no external clients have registered for transport then transport is stopped. - */ -void setPlayState(uint8_t bank, uint8_t sequence, uint8_t state); + @param scene Index of scene containing sequence + @param phrase Index of phrase containing sequence + @param sequence Index of sequence (sequence) of sequence within phrase + @param state Play state [STOPPED | STARTING | PLAYING | STOPPING] + @note STARTING will reset to start of sequence. PLAYING resumes at last played position. + @note If all sequences have stopped and no external clients have registered for local transport then local transport is stopped. +*/ +void setPlayState(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t state); /** @brief Toggles starting / stopping - * @param bank Index of bank containing sequence - * @param sequence Index (sequence) of sequence within bank - */ -void togglePlayState(uint8_t bank, uint8_t sequence); + @param scene Index of scene containing sequence + @param phrase Index of phrase containing sequence + @param sequence Index of sequence (sequence) of sequence within phrase +*/ +void togglePlayState(uint8_t scene, uint8_t phrase, uint8_t sequence); /** @brief Get sequence states encoded as 32-bit word - * @param bank Index of bank containing sequence - * @param sequence Index (sequence) of sequence within bank - * @retval uint32_t State encode as 32-bit word: [sequence, group, mode, play state] - */ -uint32_t getSequenceState(uint8_t bank, uint8_t sequence); - -/** @brief Get state of changed sequences in bank - * @param bank Index of bank - * @param start Index of first sequence to check - * @param start Index of last sequence to check - * @param states Pointer to array of uint32_t to hold results - * @retval uint8_t Quantity of changed sequences - * @note State is represented as 4 bytes encoded as single 32-bit word: [sequence, group, mode, play state] - */ -uint8_t getStateChange(uint8_t bank, uint8_t start, uint8_t end, uint32_t* states); - -/** @brief Get progress of populated sequences in bank - * @param bank Index of bank - * @param start Index of first sequence to check - * @param start Index of last sequence to check - * @param progress Pointer to array of uint16_t to hold results - * @retval uint8_t Quantity of changed sequences - * @note Progess is represented as 2 bytes encoded as single 16-bit word: [sequence, % progress] - */ -uint8_t getProgress(uint8_t bank, uint8_t start, uint8_t end, uint16_t* progress); + @param scene Index of scene containing sequence + @param phrase Index of phrase containing sequence + @param sequence Index of sequence within phrase + @retval uint32_t State encode as 4 bytes: [repeat, group, mode, play state] +*/ +uint32_t getSequenceState(uint8_t scene, uint8_t phrase, uint8_t sequence); + +/** @brief Get state of changed sequences in scene + @param states Pointer to array of uint32_t to hold results + @param max Maximum number of states to return + @retval uint32_t Quantity of changed sequences + @note State is represented as 4 bytes encoded as single 32-bit word: [phrase, sequence, mode, play state] + @note mode bits: [0..1] stop mode. [2] start mode. [7] enabled +*/ +uint32_t getStateChange(uint32_t* states, uint32_t max); + +/** @brief Get progress of each group + @retval uint8_t* Pointer to array of uint8_t holding results in percentage played +*/ +uint8_t* getProgress(); + +/** @brief Get current beat in bar + @retval uint8_t Beat +*/ +uint32_t getBeat(); /** @brief Get quantity of tracks in a sequence - * @param bank Index of bank - * @param sequence Index of sequence - * @retval uint32_t Quantity of tracks in sequence - */ -uint32_t getTracksInSequence(uint8_t bank, uint8_t sequence); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence of sequence + @retval uint32_t Quantity of tracks in sequence +*/ +uint32_t getTracksInSequence(uint8_t scene, uint8_t phrase, uint8_t sequence); + +/** @brief Set the times sequence will play + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence of sequence + @param repeat Quantity of repeats (0 to disable, 1 for play once, etc.) + @note This is actually the number of times the sequence will play, not repeat. +*/ +void setSequenceRepeat(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t repeat); + +/** @brief get the times sequence will play + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence of sequence + @retval uint8_t Quantity of repeats (0 if disabled, 1 for play once, etc.) + @note This is actually the number of times the sequence will play, not repeat. +*/ +uint8_t getSequenceRepeat(uint8_t scene, uint8_t phrase, uint8_t sequence); /** @brief Stops all sequences - */ +*/ void stop(); /** @brief Get the currently playing clock cycle - * @param bank Index of bank - * @param Sequence ID - * @retval uint32_t Playhead position in clock cycles - */ -uint32_t getPlayPosition(uint8_t bank, uint8_t sequence); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence of sequence within phrase + @retval uint32_t Playhead position in clock cycles +*/ +uint32_t getSequencePlayPosition(uint8_t scene, uint8_t phrase, uint8_t sequence); /** @brief Set the currently playing clock cycle - * @param bank Index of bank containing sequence - * @param sequence Index (sequence) of sequence within bank - * @param clock Clock cycle to position play head - */ -void setPlayPosition(uint8_t bank, uint8_t sequence, uint32_t clock); + @param scene Index of scene containing sequence + @param phrase Index of phrase containing sequence + @param sequence Index of sequence of sequence within phrase + @param clock Clock cycle to position play head +*/ +void setSequencePlayPosition(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t clock); /** @brief Get length of sequence in clock cycles - * @param bank Index of bank - * @param sequence Sequence ID - * @retval uint32_t Quantity of clock cycles in sequence - */ -uint32_t getSequenceLength(uint8_t bank, uint8_t sequence); + @param scene Index of scene containing sequence + @param phrase Index of phrase containing sequence + @param sequence Index of sequence of sequence within phrase + @retval uint32_t Quantity of clock cycles in sequence +*/ +uint32_t getSequenceLength(uint8_t scene, uint8_t phrase, uint8_t sequence); + +/** @brief Set sequence length (used mostly for clippy) + @param scene Index of scene containing sequence + @param phrase Index of phrase containing sequence + @param sequence Index of sequence (sequence) of sequence within phrase + @param length Quantity of clock cycles in sequence +*/ +void setSequenceLength(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t length); /** @brief Remove all patterns from sequence - * @param bank Index of bank - * @param sequence Sequence number - */ -void clearSequence(uint8_t bank, uint8_t sequence); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Sequence number +*/ +void clearSequence(uint8_t scene, uint8_t phrase, uint8_t sequence); /** @brief Get the quantity of playing sequences - * @retval size_t Quantity of playing sequences - */ + @retval size_t Quantity of playing sequences +*/ size_t getPlayingSequences(); /** @brief Get sequence group - * @param bank Index of bank - * @param sequence Sequence number - * @retval uint8_t Group - */ -uint8_t getGroup(uint8_t bank, uint8_t sequence); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Sequence number + @retval uint8_t Group +*/ +uint8_t getSequenceGroup(uint8_t scene, uint8_t phrase, uint8_t sequence); /** @brief Set sequence group - * @param bank Index of bank - * @param sequence Sequence number - * @param group Group index - */ -void setGroup(uint8_t bank, uint8_t sequence, uint8_t group); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Sequence number + @param group Group index +*/ +void setSequenceGroup(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t group); /** @brief Check if a sequence play state, group or mode has changed since last checked - * @param bank Index of bank - * @param sequence Index of sequence - * @retval bool True if changed - */ -bool hasSequenceChanged(uint8_t bank, uint8_t sequence); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @retval bool True if changed +*/ +bool hasSequenceChanged(uint8_t scene, uint8_t phrase, uint8_t sequence); /** @brief Adds a track to a sequence - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track to add new track after (Optional - default: add to end of sequence) - * @retval uint32_t Index of track added - */ -uint32_t addTrackToSequence(uint8_t bank, uint8_t sequence, uint32_t track = -1); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param track Index of track to add new track after (Optional - default: add to end of sequence) + @retval uint32_t Index of track added +*/ +uint32_t addTrackToSequence(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track = -1); /** @brief Removes a track from a sequence - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track - */ -void removeTrackFromSequence(uint8_t bank, uint8_t sequence, uint32_t track); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param track Index of track +*/ +void removeTrackFromSequence(uint8_t scene, uint8_t phrase, uint8_t sequence, uint32_t track); /** @brief Add tempo to sequence timebase track - * @param bank Index of bank - * @param sequence Sequence index - * @param tempo Tempo in BPM - * @param bar Bar of sequence at which to add tempo change [Optional - default: 1] - * @param tick Tick within bar at which to add tempo change [Optional - default: 0] - */ -void addTempoEvent(uint8_t bank, uint8_t sequence, uint32_t tempo, uint16_t bar = 1, uint16_t tick = 0); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param tempo Tempo in BPM + @param bar Bar of sequence at which to add tempo change [Optional - default: 1] + @param tick Tick within bar at which to add tempo change [Optional - default: 0] +*/ +void addTempoEvent(uint8_t scene, uint8_t phrase, uint8_t sequence, float tempo, uint16_t bar = 1, uint16_t tick = 0); + +/** @brief Remove tempo from sequence timebase track + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param bar Bar of sequence at which to add tempo change [Optional - default: 1] + @param tick Tick within bar at which to add tempo change [Optional - default: 0] +*/ +void removeTempoEvent(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t bar = 1, uint16_t tick = 0); /** @brief Get tempo at position within sequence -* @param bank Index of bank -* @param sequence Sequence index -* @param bar Bar at which to get tempo [Optional - default: 1] -* @param tick Tick within bar at which to get tempo [Optional - default: 0] -' @todo getTempo without time parameter should get time at current play position??? -* @retval uint32_t Tempo in BPM + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param bar Bar at which to get tempo [Optional - default: 1] + @param tick Tick within bar at which to get tempo [Optional - default: 0] + @retval float Tempo in BPM or 0.0 if no tempo in timebase */ -uint32_t getTempoAt(uint8_t bank, uint8_t sequence, uint16_t bar = 1, uint16_t tick = 0); +float getTempoAt(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t bar = 1, uint16_t tick = 0); /** @brief Add time signature to sequence - * @param bank Index of bank - * @param sequence Sequence index - * @param beats Beats per bar (numerator) - * @param type Beat type (denominator) - * @param bar Bar at which to add time signature change - * @param tick Tick within bar at which to add time signature change - */ -void addTimeSigEvent(uint8_t bank, uint8_t sequence, uint8_t beats, uint8_t type, uint16_t bar); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param bar Bar at which to add time signature change + @param timeSig Beats per bar +*/ +void addTimeSigEvent(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t bar, uint8_t timeSig); + +/** @brief Remove time signature from sequence + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param bar Bar at which to remove time signature change +*/ +void removeTimeSigEvent(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t bar); /** @brief Get time signature at position - * @param bank Index of bank - * @param sequence Sequence index - * @param bar Bar at which to time signature - * @retval uint16_t Time signature - MSB numerator, LSB denominator - */ -uint16_t getTimeSigAt(uint8_t bank, uint8_t sequence, uint16_t bar); - -/** @brief Get bank currently in MIDI learn mode - * @retval uint8_t Bank index or 0 if disabled - */ -uint8_t getMidiLearnBank(); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param bar Bar at which to get time signature + @retval uint8_t Time signature in quarter notes (beats per bar) +*/ +uint8_t getTimeSigAt(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t bar); /** @brief Get sequence currently in MIDI learn mode - * @retval uint8_t Sequence index - */ + @retval uint32_t Index of sequence +*/ uint8_t getMidiLearnSequence(); /** @brief Set the pattern editor sequence - * @param bank Bank index - * @param sequence Sequence index - */ -void setSequence(uint8_t bank, uint8_t sequence); + @param scene Index of scene + @param phrase Phrase index + @param sequence Index of sequence + @retval bool True on sucess (if sequence exists) +*/ +bool selectSequence(uint8_t scene, uint8_t phrase, uint8_t sequence); /** @brief Set sequence name - * @param bank Index of bank - * @param sequence Index of sequence - * @param name Sequence name (truncated at 16 characters) - */ -void setSequenceName(uint8_t bank, uint8_t sequence, const char* name); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param name Sequence name (truncated at 16 characters) +*/ +void setSequenceName(uint8_t scene, uint8_t phrase, uint8_t sequence, const char* name); /** @brief Get sequence name - * @param bank Index of bank - * @param sequence Index of sequence - * @retval const char* Pointer to sequence name - */ -const char* getSequenceName(uint8_t bank, uint8_t sequence); - -/** @brief Move sequence (change order of sequences) - * @param bank Index of bank - * @param sequence Index of sequence to move - * @param position Index of sequence to move this sequence, e.g. 0 to insert as first sequence - * @note Sequences after insert point are moved up by one. Bank grows if sequence or position are higher than size of bank - * @retval bool True on success - */ -bool moveSequence(uint8_t bank, uint8_t sequence, uint8_t position); - -/** @brief Insert new sequence in bank - * @param bank Index of bank - * @param sequence Index at which to insert sequence , e.g. 0 to insert as first sequence - * @note Sequences after insert point are moved up by one. Bank grows if sequence is higher than size of bank - */ -void insertSequence(uint8_t bank, uint8_t sequence); - -/** @brief Remove sequence from bank - * @param bank Index of bank - * @param sequence Index of sequence to remove - * @note Sequences after remove point are moved down by one. Bank grows if sequence is higher than size of bank - */ -void removeSequence(uint8_t bank, uint8_t sequence); - -/** @brief Update all sequence lengths and empty status - */ + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @retval const char* Pointer to sequence name +*/ +const char* getSequenceName(uint8_t scene, uint8_t phrase, uint8_t sequence); + +/** @brief Set sequence tempo + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param tempo Tempo or 0.0 to disable +*/ +void setSequenceTempo(uint8_t scene, uint8_t phrase, uint8_t sequence, float tempo); + +/** @brief Get sequence tempo + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @retval float Tempo or 0.0 if disabled +*/ +float getSequenceTempo(uint8_t scene, uint8_t phrase, uint8_t sequence); + +/** @brief Set sequence beats per bar (time signature) + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param sig Beats per bar (time signature) or 0 to disable +*/ +void setSequenceBpb(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t bpb); + +/** @brief Get sequence beats per bar (time signature) + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @retval uint8_t Beats per bar (time signature) or 0 if disabled +*/ +uint8_t getSequenceBpb(uint8_t scene, uint8_t phrase, uint8_t sequence); + +/** @brief Set sequence follow action + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param action Follow action @see FOLLOW_ACTION enum +*/ +void setSequenceFollowAction(uint8_t scene, uint8_t phrase, uint8_t sequence, uint8_t action); + +/** @brief Set sequence follow action parameter + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param param Follow action parameter +*/ +void setSequenceFollowParam(uint8_t scene, uint8_t phrase, uint8_t sequence, int16_t param); + +/** @brief Get the action to perform when sequence ends + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @retval uint8_t Follow action +*/ +uint8_t getSequenceFollowAction(uint8_t scene, uint8_t phrase, uint8_t sequence); + +/** @brief Get the parameter of follow action, e.g. offset + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @retval int16_t Follow action parameter +*/ +int16_t getSequenceFollowParam(uint8_t scene, uint8_t phrase, uint8_t sequence); + +/** @brief Update all sequence lengths and empty status +*/ void updateSequenceInfo(); -// ** Bank management functions ** - -/** @brief Set quantity of sequences in a bank - * @param bank Bank index - * @param sequences Quantity of sequences - * @note Sequences are created or destroyed as required - */ -void setSequencesInBank(uint8_t bank, uint8_t sequences); - -/** @brief Get quantity of sequences in bank - * @param bank Bank index - * @retval uint32_t Quantity of sequences - */ -uint32_t getSequencesInBank(uint32_t bank); - -/** @brief Sets the transport to start of the current bar - */ -void setTransportToStartOfBar(); - -/** @brief Selects a track to solo, muting other tracks in bank - * @param bank Index of bank - * @param sequence Index of sequence - * @param track Index of track (sequence) within sequence - * @param solo True to solo, false to clear solo - */ -void solo(uint8_t bank, uint8_t sequence, uint32_t track, bool solo); +/** @brief Selects a track to solo, muting other tracks in phrase + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param track Index of track (sequence) within sequence + @param solo True to solo, false to clear solo +*/ +void solo(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t track, bool solo); /** @brief Check if track is soloed - * @param bank Index of bank - * @param seqeunce Index of sequence - * @param track Index of track - * @retval bool True if solo - */ -bool isSolo(uint8_t bank, uint8_t sequence, uint32_t track); + @param scene Index of scene + @param phrase Index of phrase + @param sequence Index of sequence + @param track Index of track + @retval bool True if solo +*/ +bool isSolo(uint8_t scene, uint8_t phrase, uint8_t sequence, uint16_t track); // ** Transport control ** -/** @brief Locate transport to frame - * @param frame Quantity of frames (samples) from start of song to locate - */ -void transportLocate(uint32_t frame); - -/** @brief Get frame sequence at BBT position - * @param bar Bar [>0] - * @param beat Beat within bar [>0, <=beats per bar] - * @param tick Tick within beat [0) +*/ +void setExtClockPPQN(uint8_t ppqn); + +/** @brief React to a tempo tap event +*/ +void tapTempo(); + +/** @brief Enable a channel + @param channel MIDI channel + @param bool True to enable +*/ +void enableChannel(uint8_t channel, bool enable); + +/** @brief Is a channel enabled + @param channel MIDI channel + @retval bool True if channel is enabled +*/ +bool isChannelEnabled(uint8_t channel); + +/** @brief Update the quantity of frames in each tick and clock +*/ +void updateClockTiming(); + +/* Phrase handling */ + +/** @brief Get quantity of phrases + @param scene Index of scene + @retval uint8_t Quantity of phrases +*/ +uint8_t getNumPhrases(uint8_t scene); + +/** @brief Add phrase + @param scene Index of scene + @param phrase Index of phrase to insert before +*/ +void insertPhrase(uint8_t scene, uint8_t phrase); + +/** @brief Duplicate phrase + @param scene Index of scene + @param phrase Index of phrase to duplicate +*/ +void duplicatePhrase(uint8_t scene, uint8_t phrase); -/** @brief Set clock source - * @param source uint8_t Clock source [0:Internal 1:MIDI] - */ -void setClockSource(uint8_t source); +/** @brief Remove phrase + @param scene Index of scene + @param phrase Index of phrase to remove +*/ +void removePhrase(uint8_t scene, uint8_t phrase); + +/** @brief Swap position of two phrases + @param scene Index of scene + @param phrase1 Index of first phrase + @param phrase2 Index of second phrase +*/ +void swapPhrase(uint8_t scene, uint8_t phrase1, uint8_t phrase2); -/** @brief Get analog clock divisor - @retval uint8_t Analog Clocks per Beat (Analog Clock Divisor) +/** @brief Set phrase time signature in Beats per Bar + @param scene Index of scene + @param phrase Index of phrase + @param bpb Time signature in Beats per Bar */ -uint8_t getAnalogClocksBeat(); +void setPhraseBPB(uint8_t scene, uint8_t phrase, uint8_t bpb); -/** @brief Set analog clock divisor - @param Analog Clocks per Beat +/** @brief Get phrase time signature in Beats per Bar + @param scene Index of scene + @param phrase Index of phrase + @retval uint8_t Beats per Bar */ -void setAnalogClocksBeat(uint8_t analog_clock_divisor); +uint8_t getPhraseBPB(uint8_t scene, uint8_t phrase); -/** @brief Get quantity of frames in each clock cycle - * @retval jack_nframes_t Quantity of frames - */ -jack_nframes_t getFramesPerClock(double dTempo); #ifdef __cplusplus } diff --git a/zynlibs/zynseq/zynseq.py b/zynlibs/zynseq/zynseq.py index cb08077fc..53ee6fd08 100644 --- a/zynlibs/zynseq/zynseq.py +++ b/zynlibs/zynseq/zynseq.py @@ -5,7 +5,7 @@ # # A Python wrapper for zynseq library # -# Copyright (C) 2021-2024 Brian Walton +# Copyright (C) 2021-2025 Brian Walton # # ******************************************************************** # @@ -25,19 +25,22 @@ import ctypes import logging -from math import sqrt -from hashlib import new from os.path import dirname, realpath +from json import dump, dumps, load, loads from zyngine import zynthian_engine from zyngine import zynthian_controller from zyngine.zynthian_signal_manager import zynsigman +from zynlibs.zynaudioplayer import * +from zyngui import zynthian_gui_config +import zynconf +import zynautoconnect # ------------------------------------------------------------------------------- # Zynthian Step Sequencer Library Wrapper # # Most library functions are accessible directly by calling self.libseq.functionName(parameters) -# Following function wrappers provide simple access for complex data types. Access with zynseq.function_name(parameters) +# ing function wrappers provide simple access for complex data types. Access with zynseq.function_name(parameters) # # Include the following imports to access these two library objects: # from zynlibs.zynseq import zynseq @@ -45,11 +48,31 @@ # # ------------------------------------------------------------------------------- -SEQ_EVENT_BANK = 1 +MIDI_NOTE_OFF = 0x80 +MIDI_NOTE_ON = 0x90 +MIDI_POLY_PRESSURE = 0xA0 +MIDI_CONTROL = 0xB0 +MIDI_PROGRAM = 0xC0 +MIDI_CHAN_PRESSURE = 0xD0 +MIDI_PITCHBEND = 0xE0 +MIDI_SYSEX_START = 0xF0 +MIDI_TIMECODE = 0xF1 +MIDI_POSITION = 0xF2 +MIDI_SONG = 0xF3 +MIDI_TUNE = 0xF6 +MIDI_SYSEX_END = 0xF7 +MIDI_CLOCK = 0xF8 +MIDI_START = 0xFA +MIDI_CONTINUE = 0xFB +MIDI_STOP = 0xFC +MIDI_ACTIVE_SENSE = 0xFE +MIDI_RESET = 0xFF + +SEQ_EVENT_SCENE = 1 SEQ_EVENT_TEMPO = 2 SEQ_EVENT_CHANNEL = 3 SEQ_EVENT_GROUP = 4 -SEQ_EVENT_BPB = 5 +SEQ_EVENT_TIMESIG = 5 SEQ_EVENT_PLAYMODE = 6 SEQ_EVENT_SEQUENCE = 7 SEQ_EVENT_LOAD = 8 @@ -58,86 +81,243 @@ SEQ_MAX_PATTERNS = 64872 -SEQ_DISABLED = 0 -SEQ_ONESHOT = 1 -SEQ_LOOP = 2 -SEQ_ONESHOTALL = 3 -SEQ_LOOPALL = 4 -SEQ_LASTPLAYMODE = 4 - +# Play modes START & END are OR'd to provide mode +# Bits 0..1 Stop mode +SEQ_MODE_END_END = 0 # Stop at end of sequence +SEQ_MODE_END_SYNC = 1 # Stop at next sync +SEQ_MODE_END_IMMEDIATE = 2 # Stop immediately +# Bit 2 Start mode +SEQ_MODE_START_SYNC = 0 # Start at next sync +SEQ_MODE_START_IMMEDIATE = 4 # Start immediately +# Bits 8..15 hold repeats. 0 for disabled. + +# Bit 0 indicates playing SEQ_STOPPED = 0 SEQ_PLAYING = 1 -SEQ_STOPPING = 2 -SEQ_STARTING = 3 -SEQ_RESTARTING = 4 -SEQ_STOPPINGSYNC = 5 -SEQ_LASTPLAYSTATUS = 5 - -PLAY_MODES = ['Disabled', 'Oneshot', 'Loop', - 'Oneshot all', 'Loop all', 'Oneshot sync', 'Loop sync'] +SEQ_STARTING = 2 +SEQ_STOPPING = 3 +SEQ_FORCED_STOP = 4 +SEQ_STOPPING_SYNC = 5 +SEQ_CHILD_PLAYING = 6 +SEQ_CHILD_STOPPING = 8 + +FOLLOW_ACTION_NONE = 0 +FOLLOW_ACTION_RELATIVE = 1 +FOLLOW_ACTION_ABSOLUTE = 2 + +SEQ_MAX_COLUMNS = 8 + +LAUNCHER_COLS = 33 # Quantity of launcher columns (16 MIDI channels 16 Clippy + phrase launchers) +PHRASE_CHANNEL = 32 + +# Subsignals are defined inside each module. Here we define zynseq subsignals: +SS_SEQ_PLAY_STATE = 1 +SS_SEQ_STATE = 2 # Change in overal state (model) +SS_SEQ_PROGRESS = 3 +SS_SEQ_SELECT_PHRASE = 4 +SS_SEQ_TEMPO = 5 +SS_SEQ_TIMESIG = 6 +SS_SEQ_METRO = 7 + +PATTERN_PARAMS = [ + "step", # Step within pattern + "duration", # Duration of event + "offset", # Offset from start of step + "command", # MIDI command + "val1Start", # MIDI value 1 at start of event + "val1End", # MIDI value 1 at end of event + "val2Start", # MIDI value 2 at start of event + "val2End", # MIDI value 2 at end of event + "chance", # Probability % of event triggering + "stutSpd", # Stutter speed (retriggers/beat) + "stutVfx" # Stutter velocity FX value +] + + +class event_data(ctypes.Structure): + _pack_ = 1 # Crucial for matching C's packing + _fields_ = [ + ("position", ctypes.c_uint32), # Start position of event in steps + ("offset", ctypes.c_float), # Offset of event position in steps + ("duration", ctypes.c_float), # Duration of event in steps + ("command", ctypes.c_uint8), # MIDI command without channel + ("val1_start", ctypes.c_uint8), # MIDI value 1 at start of event + ("val2_start", ctypes.c_uint8), # MIDI value 2 at start of event + ("val1_end", ctypes.c_uint8), # MIDI value 1 at end of event + ("val2_end", ctypes.c_uint8), # MIDI value 2 at end of event + ("stut_speed", ctypes.c_uint8), # Stutter speed in "retriggers every 2 steps" + ("stut_velfx", ctypes.c_uint8), # Stutter velocity FX (none=0, fade-out=1, fade-in=2) + ("stut_ramp", ctypes.c_uint8), # Stutter speed ramp FX (none=0, ramp-up=1, ramp-down=2) + ("play_freq", ctypes.c_uint8), # Play/Skip note each N loops: last bit => play/skip, higher bits => loop count + # Can be used for enabling/disabling the event: 0 => play never, 1 => play on every loop + ("stut_freq", ctypes.c_uint8), # Play/Skip stutter each N loops: last bit => play/skip, higher bits => loop count + ("play_chance", ctypes.c_float), # Probability of playing (0 = not played, 0.5 = plays with 50%, 1.0 = always plays) + ("stut_chance", ctypes.c_float) # Probability of stutter (0 = not stutter, 0.5 = stutters with 50%, 1.0 = always stutters) + ] + + def set_values(self, pos, os, dur, cmd, v1s, v2s, v1e, v2e, sspd, svfx, srmp, pf, sf, pc, sc): + self.position = pos + self.osffset = os + self.duration = dur + self.command = cmd + self.val1_start = v1s + self.val2_start = v2s + self.val1_end = v1e + self.val2_end = v2e + self.stut_speed = sspd + self.stut_velfx = svfx + self.stut_ramp = srmp + self.play_freq = pf + self.stut_freq = sf + self.play_chance = pc + self.stut_chance = sc + + def __str__(self): + return f"""(position={self.position}, offset={self.offset}, duration={self.duration}, command={self.command}, + val1_start={self.val1_start}, val2_start={self.val2_start}, val1_end={self.val1_end}, val2_end={self.val2_end}, + stut_speed={self.stut_speed}, stut_velfx={self.stut_velfx}, stut_ramp={self.stut_ramp}, + play_freq={self.play_freq}, stut_freq={self.stut_freq}, + play_chance={self.play_chance}, stut_chance={self.stut_chance})""" + + +# Buffer to select pattern events (event indexes) +event_indexes_limit = 10000 +event_indexes_buffer = (ctypes.c_uint32 * event_indexes_limit)() class zynseq(zynthian_engine): - # Subsignals are defined inside each module. Here we define zynseq subsignals: - SS_SEQ_PLAY_STATE = 1 - SS_SEQ_REFRESH = 2 - SS_SEQ_PROGRESS = 3 - # Initiate library - performed by zynseq module - def __init__(self, state_manager=None): + def __init__(self, state_manager=None, proc=None): self.state_manager = state_manager - self.changing_bank = False + self.proc = proc + self.custom_gui_fpath = "/zynthian/zynthian-ui/zyngui/zynthian_widget_tempo.py" + try: - self.libseq = ctypes.cdll.LoadLibrary( - dirname(realpath(__file__))+"/build/libzynseq.so") + self.libseq = ctypes.cdll.LoadLibrary(dirname(realpath(__file__))+"/build/libzynseq.so") self.libseq.getSequenceName.restype = ctypes.c_char_p - self.libseq.addNote.argtypes = [ - ctypes.c_uint32, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_float, ctypes.c_float] + + self.libseq.addNote.argtypes = [ctypes.c_uint32, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_float, ctypes.c_float] + self.libseq.pastePatternBuffer.argtypes = [ctypes.c_uint32, ctypes.c_int32, ctypes.c_float, ctypes.c_int8, ctypes.c_bool] + self.libseq.getPatternSelectionIndexes.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32, + ctypes.c_uint32, ctypes.c_int32, ctypes.c_uint8, ctypes.c_uint8] + self.libseq.getEventDataAt.argtypes = [ctypes.c_uint32, ctypes.POINTER(event_data)] + self.libseq.getBufferEventDataAt.argtypes = [ctypes.c_uint32, ctypes.POINTER(event_data)] + self.libseq.getNoteData.argtypes = [ctypes.c_uint32, ctypes.c_uint8, ctypes.POINTER(event_data)] + self.libseq.setNoteData.argtypes = [ctypes.c_uint32, ctypes.c_uint8, ctypes.POINTER(event_data)] + #self.libseq.getNoteData.restype = ctypes.c_int32 self.libseq.getNoteDuration.restype = ctypes.c_float + self.libseq.changeDurationAll.argtypes = [ctypes.c_float] + self.libseq.changeVelocityAll.argtypes = [ctypes.c_float] + self.libseq.changeDurationList.argtypes = [ctypes.c_float, ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32] + self.libseq.changeVelocityList.argtypes = [ctypes.c_float, ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32] + self.libseq.getNoteOffset.restype = ctypes.c_float - self.libseq.setNoteOffset.argtypes = [ - ctypes.c_uint32, ctypes.c_uint8, ctypes.c_float] - self.libseq.addControl.argtypes = [ - ctypes.c_uint32, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_float, ctypes.c_float] + self.libseq.setNoteOffset.argtypes = [ctypes.c_uint32, ctypes.c_uint8, ctypes.c_float] + + self.libseq.addControl.argtypes = [ctypes.c_uint32, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_float, ctypes.c_float] self.libseq.getControlDuration.restype = ctypes.c_float self.libseq.getControlOffset.restype = ctypes.c_float - self.libseq.setControlOffset.argtypes = [ - ctypes.c_uint32, ctypes.c_uint8, ctypes.c_float] + self.libseq.setControlOffset.argtypes = [ctypes.c_uint32, ctypes.c_uint8, ctypes.c_float] + self.libseq.setSwingAmount.argtypes = [ctypes.c_float] self.libseq.getSwingAmount.restype = ctypes.c_float + self.libseq.setSwingDiv.argtypes = [ctypes.c_uint32] + self.libseq.getSwingDiv.restype = ctypes.c_uint32 self.libseq.setHumanTime.argtypes = [ctypes.c_float] self.libseq.getHumanTime.restype = ctypes.c_float self.libseq.setHumanVelo.argtypes = [ctypes.c_float] self.libseq.getHumanVelo.restype = ctypes.c_float self.libseq.setPlayChance.argtypes = [ctypes.c_float] self.libseq.getPlayChance.restype = ctypes.c_float + + self.libseq.setNotePlayChance.argtypes = [ctypes.c_uint32, ctypes.c_uint8, ctypes.c_float] + self.libseq.getNotePlayChance.argtypes = [ctypes.c_uint32, ctypes.c_uint8] + self.libseq.getNotePlayChance.restype = ctypes.c_float + + self.libseq.setNoteStutterChance.argtypes = [ctypes.c_uint32, ctypes.c_uint8, ctypes.c_float] + self.libseq.getNoteStutterChance.argtypes = [ctypes.c_uint32, ctypes.c_uint8] + self.libseq.getNoteStutterChance.restype = ctypes.c_float + self.libseq.getTempo.restype = ctypes.c_double self.libseq.setTempo.argtypes = [ctypes.c_double] + self.libseq.getTempoAt.restype = ctypes.c_float + self.libseq.getTempoAt.argtypes = [ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint16, ctypes.c_uint16] + self.libseq.addTempoEvent.argtypes = [ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_float, ctypes.c_uint16, ctypes.c_uint16] self.libseq.getMetronomeVolume.restype = ctypes.c_float self.libseq.setMetronomeVolume.argtypes = [ctypes.c_float] - self.libseq.getStateChange.argtypes = [ - ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.POINTER(ctypes.c_uint32)] - self.libseq.getStateChange.restype = ctypes.c_uint8 - self.libseq.getProgress.argtypes = [ - ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.POINTER(ctypes.c_uint16)] - self.libseq.getProgress.restype = ctypes.c_uint8 + self.libseq.getStateChange.argtypes = [ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32] + self.libseq.getStateChange.restype = ctypes.c_uint32 + self.libseq.getProgress.restype = ctypes.POINTER(ctypes.c_uint8) + + # Pattern functions + self.libseq.getPattern.restype = ctypes.c_uint32 + self.libseq.getPatternAt.restype = ctypes.c_uint32 + self.libseq.convertPattern.argtypes = [ctypes.c_int32, ctypes.c_char_p] + self.libseq.convertPattern.restype = ctypes.c_char_p + + # Sequence functions + self.libseq.setSequenceTempo.argtypes = [ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_float] + self.libseq.getSequenceTempo.argtypes = [ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8] + self.libseq.getSequenceTempo.restype = ctypes.c_float + self.libseq.setSequenceBpb.argtypes = [ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8] + self.libseq.getSequenceBpb.restype = ctypes.c_uint8 + self.libseq.setSequenceFollowAction.argtypes = [ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8] + self.libseq.getSequenceFollowAction.restype = ctypes.c_uint8 + self.libseq.setSequenceFollowParam.argtypes = [ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_int16] + self.libseq.getSequenceFollowParam.restype = ctypes.c_int16 + + self.libseq.convertToJson.restype = ctypes.c_char_p + self.libseq.getState.restype = ctypes.c_char_p + + self.PPQN = self.libseq.getPPQN() self.libseq.init(bytes("zynseq", "utf-8")) + except Exception as e: self.libseq = None print("Can't initialise zynseq library: %s" % str(e)) - self.zctrl_tempo = zynthian_controller(self, 'tempo', { + self.zctrl_tempo = zynthian_controller(self, 'bpm', { + 'name': 'BPM', 'is_integer': False, 'value_min': 10.0, - 'value_max': 420, + 'value_max': 420.0, 'value': self.libseq.getTempo(), 'nudge_factor': 1.0 }) + self.zctrl_metro_mode = zynthian_controller(self, 'metronome_enable', { + 'name': 'Metronome Mode', + 'labels': ['OFF', 'AUTO', 'ON', 'INTRO', "NO ACCENT", "SILENT"], + 'value': self.libseq.getMetronomeMode() + }) + self.zctrl_metro_volume = zynthian_controller(self, 'metronome_volume', { + 'name': 'Metronome Volume', + 'value_min': 0, + 'value_max': 100, + 'value': int(100 * self.libseq.getMetronomeVolume()) + }) + self.zctrl_ppqn = zynthian_controller(self, 'ppqn', { + 'name': 'Clock PPQN', + 'value_min': 1, + 'value_max': 48, + 'value_default': 24, + 'value': self.libseq.getExtClockPPQN() + }) - self.bank = None - self.select_bank(1, True) + # Cache sequence info for launchers to reduce access to libseq + self.phrases = 0 # Quantity of launcher slots/rows/phrases + self.scene = 0 # Currently selected scene + self.chan = 0 # Currently selected channel + self.phrase = 0 # Currently selected phrase + self.seq_in_scene = 0 # Quantity of sequence in the selected scene + self.playing_sequences = 0 # Quantity of playing sequences + self.pause_update = False + self.progress = [0] * LAUNCHER_COLS + self.bpb = 4 + self.beat = 0 # Current beat of bar + self.clippy = None # Clippy engine object + self.reset() # Destroy instance of shared library def destroy(self): @@ -145,149 +325,150 @@ def destroy(self): ctypes.dlclose(self.libseq._handle) self.libseq = None - def update_state(self): - num_seq = self.col_in_bank ** 2 - states = (ctypes.c_uint32 * num_seq)() - count = self.libseq.getStateChange(self.bank, 0, num_seq, states) - for i in range(count): - state = states[i] & 0xff - mode = (states[i] >> 8) & 0xff - group = (states[i] >> 16) & 0xff - seq = (states[i] >> 24) & 0xff - zynsigman.send(zynsigman.S_STEPSEQ, self.SS_SEQ_PLAY_STATE, - bank=self.bank, seq=seq, state=state, mode=mode, group=group) - self.update_progress() - - def update_progress(self): - num_seq = self.col_in_bank ** 2 - progress = (ctypes.c_uint16 * num_seq)() - count = self.libseq.getProgress(self.bank, 0, num_seq, progress) - for i in range(count): - seq = (progress[i] >> 8) & 0xff - prog = progress[i] & 0xff - zynsigman.send(zynsigman.S_STEPSEQ, self.SS_SEQ_PROGRESS, - bank=self.bank, seq=seq, progress=prog) - - # Function to select a bank for edit / control - # bank: Index of bank - # force: True to force bank selection even if same as current bank - def select_bank(self, bank=None, force=False): - if self.changing_bank: - return - if bank is None: - bank = self.bank - else: - if bank < 1 or bank > 64 or bank == self.bank and not force: - return - self.changing_bank = True - if self.libseq.getSequencesInBank(bank) == 0: - self.build_default_bank(bank) - self.seq_in_bank = self.libseq.getSequencesInBank(bank) - # WARNING!!! Limited to 8 to avoid issues with GUI zynpad that have 8x8 = 64 pads - self.col_in_bank = min(8, int(sqrt(self.seq_in_bank))) - self.bank = bank - zynsigman.send(zynsigman.S_STEPSEQ, self.SS_SEQ_REFRESH) - self.changing_bank = False - - # Build a default bank with 16 sequences in grid of midi channels 1,2,3,10 - # bank: Index of bank to rebuild - def build_default_bank(self, bank): - if self.libseq: - self.libseq.setSequencesInBank(bank, 0) - self.libseq.setSequencesInBank(bank, 16) - for column in range(4): - channel = column - for row in range(4): - seq = row + 4 * column - self.set_sequence_name(bank, seq, "{}".format( - self.libseq.getPatternAt(bank, seq, 0, 0))) - self.libseq.setGroup(bank, seq, channel) - self.libseq.setChannel(bank, seq, 0, channel) - - # Function to add / remove sequences to change bank size - # new_columns: Quantity of columns (and rows) of new grid - def update_bank_grid(self, new_columns): - # To avoid odd behaviour we stop all sequences from playing before changing grid size (blunt but effective!) - for seq in range(self.libseq.getSequencesInBank(self.bank)): - self.libseq.setPlayState(self.bank, seq, SEQ_STOPPED) - channels = [] - groups = [] - for column in range(new_columns): - if column < self.col_in_bank: - channels.append(self.libseq.getChannel( - self.bank, column * self.col_in_bank, 0)) - groups.append(self.libseq.getGroup( - self.bank, column * self.col_in_bank)) - else: - channels.append(column) - groups.append(column) - delta = new_columns - self.col_in_bank - if delta > 0: - # Growing grid so add extra sequences - for column in range(self.col_in_bank): - for row in range(self.col_in_bank, self.col_in_bank + delta): - pad = row + column * new_columns - self.libseq.insertSequence(self.bank, pad) - self.libseq.setChannel(self.bank, pad, 0, channels[column]) - self.libseq.setGroup(self.bank, pad, groups[column]) - self.set_sequence_name(self.bank, pad, "%s" % (pad + 1)) - for column in range(self.col_in_bank, new_columns): - for row in range(new_columns): - pad = row + column * new_columns - self.libseq.insertSequence(self.bank, pad) - self.libseq.setChannel(self.bank, pad, 0, column) - self.libseq.setGroup(self.bank, pad, column) - self.set_sequence_name( - self.bank, pad, "{}".format(pad + 1)) - elif delta < 0: - # Shrinking grid so remove excess sequences - # Lose excess columns - self.libseq.setSequencesInBank( - self.bank, new_columns * self.col_in_bank) - - # Lose exess rows - for col in range(new_columns - 1, -1, -1): - for row in range(self.col_in_bank - 1, new_columns - 1, -1): - offset = self.col_in_bank * col + row - self.libseq.removeSequence(self.bank, offset) - self.seq_in_bank = self.libseq.getSequencesInBank(self.bank) - self.col_in_bank = min(8, int(sqrt(self.seq_in_bank))) - zynsigman.send(zynsigman.S_STEPSEQ, self.SS_SEQ_REFRESH) + def reset(self): + self.libseq.reset() + self.refresh_state() # Load a zynseq file # filename: Full path and filename def load(self, filename): self.libseq.load(bytes(filename, "utf-8")) - self.select_bank(1, True) # TODO: Store selected bank in seq file + + # ------------------------------------------------------------------- + # Channel management + # ------------------------------------------------------------------- + + def set_midi_channel(self, chan, sequence, track, channel): + self.libseq.setChannel(chan, sequence, track, channel) + self.refresh_state() + + def enable_channel(self, channel, enable, refresh=False): + self.libseq.enableChannel(channel, enable) + self.refresh_state(refresh) + + # ------------------------------------------------------------------- + # Miscelaneous + # ------------------------------------------------------------------- + + # Get sequence name + # Returns: Sequence name (maximum 16 characters) + def get_sequence_name(self, scene, phrase, sequence): + if self.libseq: + name = self.libseq.getSequenceName(scene, phrase, sequence).decode("utf-8") + if not name: + name = f"{chr(ord('A') + phrase)}{sequence + 1}" + return name + else: + return f"{sequence}" + + # ------------------------------------------------------------------- + # Phrase Management + # ------------------------------------------------------------------- + + def insert_phrase(self, scene, phrase=None): + """ Insert a row of sequences to the current scene + + :phrase: Index of phrase to insert (Default: append) + """ + + if phrase is None: + phrase = self.phrases + self.libseq.insertPhrase(scene, phrase) + if scene == self.scene and self.clippy: + self.clippy.insert_phrase(phrase) + self.refresh_state() + + def duplicate_phrase(self, scene, phrase=None): + """ Insert a row of sequences to the current scene + + :phrase: Index of phrase to insert (Default: append) + """ + + if phrase is None: + phrase = self.phrases + self.libseq.duplicatePhrase(scene, phrase) + if scene == self.scene and self.clippy: + self.clippy.insert_phrase(phrase) + # TODO Duplicate clips + self.refresh_state() + + def remove_phrase(self, scene, phrase): + if self.phrases < 2: + return # TODO: What should be the minimum quantity of launchers? + self.libseq.removePhrase(scene, phrase) + if scene == self.scene and self.clippy: + self.clippy.remove_phrase(phrase) + if self.phrase == phrase: + self.phrase = max(0, self.phrase - 1) + self.refresh_state() + + def swap_phrase(self, scene, phrase1, phrase2): + self.libseq.swapPhrase(scene, phrase1, phrase2) + if self.clippy and scene == self.scene: + self.clippy.swap_phrase(phrase1, phrase2) + if self.phrase == phrase1: + self.phrase = phrase2 + elif self.phrase == phrase2: + self.phrase = phrase1 + self.refresh_state() + + def select_phrase(self, phrase, force=False): + """ + Select a phrase + + :param: phrase Index of phrase + :param: force True to select phrase even if same as currently selected + """ + + if (phrase >= self.phrases): + phrase = self.phrases - 1 + if (phrase < 0): + phrase = 0 + if not force and phrase == self.phrase: + return False + self.phrase = phrase + zynsigman.send(zynsigman.S_STEPSEQ, SS_SEQ_SELECT_PHRASE, phrase=phrase) + + # ------------------------------------------------------------------- + # Pattern and event management + # ------------------------------------------------------------------- # Load a zynseq pattern file # patnum: Pattern number # filename: Full path and filename def load_pattern(self, patnum, filename): - self.libseq.load_pattern(int(patnum), bytes(filename, "utf-8")) - - # Save a zynseq file - # filename: Full path and filename - # Returns: True on success - def save(self, filename): - if self.libseq: - return self.libseq.save(bytes(filename, "utf-8")) - return None + try: + with open(filename, "r") as f: + state = load(f) + patn = state["patn"] + patn_str = dumps(patn) + except: + try: + # Legacy binary file format + patn_str = self.libseq.convertPattern(int(patnum), bytes(filename, "utf-8")).decode("utf-8") + self.libseq.freeState() + patn = loads(patn_str) + except: + return + if patn: + self.libseq.setPattern(patnum, bytes(patn_str, "utf-8")) + self.state["patns"][str(patnum)] = patn # Save a zynseq pattern file # patnum: Pattern number # filename: Full path and filename # Returns: True on success def save_pattern(self, patnum, filename): - if self.libseq: - return self.libseq.save_pattern(int(patnum), bytes(filename, "utf-8")) - return None - - # Set sequence name - # name: Sequence name (truncates at 16 characters) - def set_sequence_name(self, bank, sequence, name): - if self.libseq: - self.libseq.setSequenceName(bank, sequence, bytes(name, "utf-8")) + try: + state = { + "schema_version": self.state_manager.get_schema(), + "patn": self.state["patns"][str(patnum)] + } + with open(filename, "w") as f: + dump(state, f) + return True + except: + pass # Check if pattern is empty # Returns: True is pattern is empty @@ -296,13 +477,64 @@ def is_pattern_empty(self, patnum): return self.libseq.isPatternEmpty(patnum) return False - # Get sequence name - # Returns: Sequence name (maximum 16 characters) - def get_sequence_name(self, bank, sequence): - if self.libseq: - return self.libseq.getSequenceName(bank, sequence).decode("utf-8") - else: - return "%d" % (sequence) + def remove_pattern(self, chan, sequence, track, time): + self.libseq.removePattern(chan, sequence, track, time) + + def add_pattern(self, chan, sequence, track, time, pattern, force=False): + if self.libseq.addPattern(chan, sequence, track, time, pattern, force): + return True + + def get_note_data(self, step, note, cp_buffer=False): + """ Get note full data searching by step & note number in the currently loaded pattern + + step: Step index in the current pattern + note: Note number + cp_buffer: Note number + Returns: A data struct with event data + """ + + evdata = event_data() + res = self.libseq.getNoteData(step, note, evdata, cp_buffer) + if res >= 0: + #logging.debug(f"Note ({step}, {note}) data => {evdata}") + return evdata + + def set_note_data(self, step, note, evdata): + """ Set note data searching by step & note number in the currently loaded pattern. + All event data is overwritten except: position, offset duration, command and val1_start, + + step: Step index in the current pattern + note: Note number + evdata: event_data object + Returns: A data struct with event data + """ + + return self.libseq.setNoteData(step, note, evdata) + + def get_pattern_selection(self, pattern, step1, step2, note1, note2): + """ Get event indexes from pattern in the specified step & note range. + + pattern: Pattern index + step1: Start of step range + step2: End of step range + note1: Start of note range + note2: End of note range + Returns: A list of event indexes + """ + + # Initially, event indexes are copied into a ctypes buffer array + n = self.libseq.getPatternSelectionIndexes(pattern, event_indexes_buffer, event_indexes_limit, + step1, step2, note1, note2) + # Then copied and returned in a list + res = [] + for i in range(n): + res.append(event_indexes_buffer[i]) + return res + #return event_indexes_buffer + + # ------------------------------------------------------------------- + # MIDI transport & clock settings + # ------------------------------------------------------------------- # Request JACK transport start # client: Name to register with transport to avoid other clients stopping whilst in use @@ -326,101 +558,213 @@ def transport_toggle(self, client): def set_tempo(self, tempo): self.zctrl_tempo.set_value(tempo) + zynaudioplayer.set_tempo(tempo) def get_tempo(self): return self.libseq.getTempo() - def update_tempo(self): - self.set_tempo(self.libseq.getTempo()) - def nudge_tempo(self, offset): self.zctrl_tempo.nudge(offset) + def tap_tempo(self): + self.libseq.tapTempo() + + # ------------------------------------------------------------------- + # Zynseq Zctrls management + # ------------------------------------------------------------------- + def send_controller_value(self, zctrl): if zctrl == self.zctrl_tempo: self.libseq.setTempo(zctrl.value) - self.state_manager.audio_player.engine.player.set_tempo( - zctrl.value) - - def set_midi_channel(self, bank, sequence, track, channel): - self.libseq.setChannel(bank, sequence, track, channel) + #self.state_manager.audio_player.engine.player.set_tempo(zctrl.value) + zynsigman.send(zynsigman.S_STEPSEQ, SS_SEQ_TEMPO, tempo=zctrl.value) + elif zctrl == self.zctrl_metro_mode: + self.libseq.setMetronomeMode(zctrl.value) + zynsigman.send(zynsigman.S_STEPSEQ, SS_SEQ_METRO, mode=zctrl.value, volume=self.zctrl_metro_volume.value) + elif zctrl == self.zctrl_metro_volume: + self.libseq.setMetronomeVolume(zctrl.value / 100.0) + zynsigman.send(zynsigman.S_STEPSEQ, SS_SEQ_METRO, mode=self.zctrl_metro_mode.value, volume=zctrl.value) + elif zctrl == self.zctrl_ppqn: + self.libseq.setExtClockPPQN(zctrl.value) + + # ------------------------------------------------------------------- + # Zynseq MIDI learn ==> Is this still used? + # ------------------------------------------------------------------- + + def enable_midi_learn(self, chan, sequence): + try: + self.libseq.enableMidiLearn(chan, sequence, ctypes.py_object(self), self.midi_learn_cb) + except Exception as e: + logging.error(e) - def set_group(self, bank, sequence, group): - self.libseq.setGroup(bank, sequence, group) + def disable_midi_learn(self): + try: + self.libseq.enableMidiLearn(0, 0, ctypes.py_object(self), self.midi_learn_cb) + except Exception as e: + logging.error(e) - def set_sequences_in_bank(self, bank, count): - self.libseq.setSequencesInBank(bank, count) + # ------------------------------------------------------------------- + # State management + # ------------------------------------------------------------------- - def insert_sequence(self, bank, sequence): - self.libseq.insertSequence(bank, sequence) + def update_state(self): + # Get all pending states, send signals for each, update phrase lauchers and send signals if necessary + # State is represented as 4 bytes encoded as single 32-bit word: [sequence, group, mode, play state] + # mode bits: [0..1] stop mode. [2] start mode. [7] enabled. + + tempo = self.libseq.getTempo() + if tempo != self.zctrl_tempo.value: + self.zctrl_tempo.set_value(tempo) + size = self.phrases * 33 + states = (ctypes.c_uint32 * size)() + count = self.libseq.getStateChange(states, size) + if count: + self.playing_sequences = self.libseq.getPlayingSequences() + bpb = self.libseq.getBpb() + if bpb != self.bpb: + self.bpb = bpb + zynsigman.send(zynsigman.S_STEPSEQ, SS_SEQ_TIMESIG, bpb=bpb) + # Iterate state changes + for i in range(count): + if self.pause_update: + return # Stop processing updates if changing structure + phrase = (states[i] >> 24) & 0xff + chan = min((states[i] >> 16) & 0xff, 32) + mode = (states[i] >> 8) & 0xff + state = states[i] & 0xff + try: + if chan == PHRASE_CHANNEL: + info = self.state["scenes"][self.scene]["phrases"][phrase] + else: + info = self.state["scenes"][self.scene]["phrases"][phrase]["sequences"][chan] + except: + logging.warning(f"No launcher info for sequence ({phrase},{chan})") + continue + info["state"] = state + info["mode"] = mode + zynsigman.send(zynsigman.S_STEPSEQ, SS_SEQ_PLAY_STATE, phrase=phrase, chan=chan) + # Update progress + progress = self.libseq.getProgress() + for i in range(33): + self.progress[i] = progress[i] # TODO: Can we just point at getProgress()? + self.beat = self.libseq.getBeat() + + def refresh_state(self, send=True): + self.state = loads(self.libseq.getState().decode("utf-8")) + self.libseq.freeState() + try: + self.state["scenes"][0]["phrases"][0] + except: + logging.warning("Invalid zynseq - rebuilding default") + self.libseq.reset() + self.state = loads(self.libseq.getState().decode("utf-8")) + self.libseq.freeState() + self.phrases = len(self.state["scenes"][self.scene]["phrases"]) + try: + if self.state["tempo"] != self.zctrl_tempo.value: + self.zctrl_tempo.set_value(self.state["tempo"]) + except: + logging.warning("Failed to set tempo") + try: + if self.state["bpb"] != self.bpb: + self.bpb = self.state["bpb"] + zynsigman.send(zynsigman.S_STEPSEQ, SS_SEQ_TIMESIG, bpb=self.bpb) + except: + logging.warning("Failed to set bpb") + if send: + zynsigman.send(zynsigman.S_STEPSEQ, SS_SEQ_STATE) + + def set_state(self, state): + result = self.libseq.setState(bytes(dumps(state), "utf-8")) + if not result: + for chain in self.state_manager.chain_manager.chains.values(): + if chain.midi_chan is not None: + self.libseq.enableChannel(chain.midi_chan, True) + self.refresh_state() + return result + + def set_sequence_param(self, scene, phrase, sequence, param, value): + """ Set a sequence parameter value + + scene: Index of scene + phrase: Index of phrase + sequence: Index of sequence + param: Name of parameter (camelCase) + value: Parameter value + Return: True on success + + param may be: mode, group, name, repeat, followaction, followParam + """ - def set_beats_per_bar(self, bpb): - self.libseq.setBeatsPerBar(bpb) + try: + if sequence == PHRASE_CHANNEL: + state_seq = self.state["scenes"][scene]["phrases"][phrase] + else: + state_seq = self.state["scenes"][scene]["phrases"][phrase]["sequences"][sequence] + fn_name = f"setSequence{param[0].upper()}{param[1:]}" + fn = getattr(self.libseq, fn_name) + if type(value) is str: + fn(scene, phrase, sequence, bytes(value, "utf-8")) + else: + fn(scene, phrase, sequence, value) + state_seq[param] = value + return True + except Exception as e: + logging.warning(f"Failed to set sequence parameter {param}={value}: {e}") + return False - def set_play_mode(self, bank, sequence, mode): - self.libseq.setPlayMode(bank, sequence, mode) + def get_sequence_param(self, scene, phrase, sequence, param): + """ Get a sequence parameter value - def remove_pattern(self, bank, sequence, track, time): - self.libseq.removePattern(bank, sequence, track, time) + scene: Index of scene + phrase: Index of phrase + sequence: Index of sequence + param: Name of parameter (camelCase) + Returns: Parameter value - def add_pattern(self, bank, sequence, track, time, pattern, force=False): - if self.libseq.addPattern(bank, sequence, track, time, pattern, force): - return True + param may be: mode, group, name, repeat, followaction, followParam + """ - def enable_midi_learn(self, bank, sequence): try: - self.libseq.enableMidiLearn( - bank, sequence, ctypes.py_object(self), self.midi_learn_cb) + if sequence == PHRASE_CHANNEL: + return self.state["scenes"][scene]["phrases"][phrase][param] + else: + state_seq = self.state["scenes"][scene]["phrases"][phrase]["sequences"][sequence][param] except Exception as e: - logging.error(e) + logging.warning(f"Failed to get sequence parameter {param}: {e}") + return 0 + + def get_pattern(self, scene, phrase, sequence, track, pos): + """ Get the index of a pattern within a sequence + + scene: Index of scene + phrase: Index of phrase + sequence: Index of sequence + track: Index of track + pos: Position within track + Returns: Pattern index or None on failure + """ - def disable_midi_learn(self): try: - self.libseq.enableMidiLearn( - 0, 0, ctypes.py_object(self), self.midi_learn_cb) + return self.state["scenes"][scene]["phrases"][phrase]["sequences"][sequence]["tracks"][track]["patns"][str(pos)] except Exception as e: - logging.error(e) + logging.warning(f"Failed to get pattern: {e}") + return None - def get_riff_data(self): - fpath = "/tmp/snapshot.zynseq" - try: - # Save to tmp - self.save(fpath) - # Load binary data - with open(fpath, "rb") as fh: - riff_data = fh.read() - logging.info("Loading RIFF data...\n") - return riff_data + def get_pattern_event(self, pattern, event, param): + """ Get a pattern event parameter from the state cache - except Exception as e: - logging.error("Can't get RIFF data! => {}".format(e)) - return None + pattern: Index of pattern + event: Index of event + param: Name of parameter + Returns: Parameter value + """ - def restore_riff_data(self, riff_data): - fpath = "/tmp/snapshot.zynseq" try: - # Save RIFF data to tmp file - with open(fpath, "wb") as fh: - fh.write(riff_data) - logging.info("Restoring RIFF data...\n") - # Load from tmp file - if self.load(fpath): - self.filename = "snapshot" - return True - + idx = PATTERN_PARAMS.index(param) + return self.state["patns"][str(pattern)]["events"][event][idx] except Exception as e: - logging.error("Can't restore RIFF data! => {}".format(e)) - return False + logging.warning(f"Failed to get pattern event parameter {param}") - def get_xy_from_pad(self, pad): - col = pad // self.col_in_bank - row = pad % self.col_in_bank - return (col, row) - - def get_pad_from_xy(self, col, row): - if col < self.col_in_bank and row < self.col_in_bank: - return col * self.col_in_bank + row - else: - return -1 # ------------------------------------------------------------------------------- diff --git a/zynlibs/zynseq/zynseq.txt b/zynlibs/zynseq/zynseq.txt index fd4e5dfe4..220e10a8c 100644 --- a/zynlibs/zynseq/zynseq.txt +++ b/zynlibs/zynseq/zynseq.txt @@ -2,7 +2,7 @@ Description of zynseq code Zynseq is Zynthian's step sequencer. It has a hierarchical structure of C++ classes: - A scene (previously known as "bank") contains 1 or more sequences + A phrase (previously known as "bank") contains 1 or more sequences A sequence contains 1 or more tracks A track contains 0 or more patterns A pattern contains 0 or more step events @@ -59,9 +59,9 @@ A sequence is a class that acts as a containter for parallel tracks. It provides Flag to indicate sequence is empty (may contain empty tracks) Sequence name (for UI - not used internally) -A scene is a class that acts as a containter for sequences. It allows the grouping of sequences, e.g. for display as pages, songs, scenes, etc. +A phrase is a class that acts as a containter for sequences. It allows the grouping of sequences, e.g. for display as pages, scenes, phrase, etc. -A SEQ_EVENT event is a structure describing a MIDI event and time relative to start of pattern. This is derived from the STEP_EVENT context (track, sequence, scene) to provide the data used during playback. +A SEQ_EVENT event is a structure describing a MIDI event and time relative to start of pattern. This is derived from the STEP_EVENT context (track, sequence, phrase) to provide the data used during playback. Manipulating Events =================== @@ -77,7 +77,7 @@ Playback (and live record) is handled by the JACK process callback only if the J For each JACK period: Each pending clock pulse is identified and processed: - A counter of clocks within the beat increments and resets after the configured PPQN (24) + A counter of clocks within the beat increments and resets after the configured PPQN (96) This is used to identify the start of a beat A clock pulse is sent to the sequence manager which populates a schedule of events due within this JACK period Note: this may include events that occur in the future, e.g. NOTE OFF events diff --git a/zynthian_main.py b/zynthian_main.py index 88caabdf1..e76684522 100755 --- a/zynthian_main.py +++ b/zynthian_main.py @@ -36,7 +36,6 @@ from zyncoder.zyncore import lib_zyncore from zyngui.zynthian_gui import zynthian_gui from zyngui import zynthian_gui_keybinding -from zynlibs.zynseq import * # ****************************************************************************** # ------------------------------------------------------------------------------ @@ -73,13 +72,6 @@ def zynpot_cb(i, dval): # Reparent Top Window using GTK XEmbed protocol features # ------------------------------------------------------------------------------ - -def flushflush(): - for i in range(1000): - print("FLUSHFLUSHFLUSHFLUSHFLUSHFLUSHFLUSH") - zynthian_gui_config.top.after(200, flushflush) - - if zynthian_gui_config.wiring_layout == "EMULATOR": top_xid = zynthian_gui_config.top.winfo_id() print("Zynthian GUI XID: " + str(top_xid)) @@ -89,9 +81,8 @@ def flushflush(): zynthian_gui_config.top.geometry('-10000-10000') zynthian_gui_config.top.overrideredirect(True) zynthian_gui_config.top.wm_withdraw() - flushflush() - zynthian_gui_config.top.after( - 1000, zynthian_gui_config.top.wm_deiconify) + #flush() + zynthian_gui_config.top.after(1000, zynthian_gui_config.top.wm_deiconify) # ------------------------------------------------------------------------------ diff --git a/zynthian_state_schema.py b/zynthian_state_schema.py index bfe7be179..b773ad20a 100644 --- a/zynthian_state_schema.py +++ b/zynthian_state_schema.py @@ -22,7 +22,7 @@ # ****************************************************************************** ZynthianState = { - "schema_version": 1, # Version of state (snapshot) model + "schema_version": 4, # Version of state (snapshot) model "last_snapshot_fpath": "/zynthian/zynthian-my-data/snapshots/000/My Snapshot 1.zss", # Full path and filename of last loaded snapshot "midi_profile_state": { # MIDI Profile TODO: Document midi profile "MASTER_BANK_CHANGE_UP": "", @@ -51,114 +51,187 @@ "USB-1.1.1 CH345 MIDI IN": "VZ-1 IN", # Friendly name mapped by uid } # ... More ports }, - "chains": { # Dictionary of chains indexed by chain ID + "chains": { # Dictionary of chains indexed by chain id (id=0 is main mixbus chain) "1": { # Chain 1 - "title": "My first chain", # Chain title (optional) - "mixer_chan": 0, # Chain audio mixer channel (may be None) + "title": "My first chain", # Optional chain title "midi_chan": 0, # Chain MIDI channel (may be None) - "current_processor": 0, # Index of the processor last selected within chain (Should this go in GUI section?) + "midi_thru": True, # True if chain passes MIDI + "audio_thru": False, # True if chain passes audio + "zmop_index": 1, # Index of zynmidirouter midi output (zmop) this chain uses "slots": [ # List of slots in chain in serial slot order { # Dictionary of processors in first slot "1": "PT", # Processor type indexed by processor id }, # ... more processors in this slot ], # ... More slots - "fader_pos": 1, # Index of slot where fader is (divides pre/post fader audio effects) - #"cc_route": [] # List of 0/1 indicating if the indexed cc is routed directly to engine (optional) - "cc_route": [] # List of MIDI CC to be routed directly to engine (optional) - } + "cc_route": [] # Optional list of MIDI CC to be routed directly to engine + }, # ... More chains }, "zs3": { # Dictionary of ZS3's indexed by chan/prog or ZS3-x "zs3-0": { # ZS3 state when snapshot saved "title": "Last state", # ZS3 title - "active_chain": "01", # Active chain ID (optional, overides base value) + "active_chain": "1", # Optional active chain id (overides base value) "processors": { # Dictionary of processor settings "1": { # Processor id:1 - "bank_info": ["HB Steinway D", 0, "Grand Steinway D (Hamburg)", "D4:A", "HB Steinway Model D"], # Bank ID - "preset_info": None, # Preset ID - "controllers": { # Dictionary of controllers (optional, overrides preset default value) + "restore": True, # Optional False to omit from processor parameters from restore (Default: True) + "bank_info": ["HB Steinway D", 0, "Grand Steinway D (Hamburg)", "D4:A", "HB Steinway Model D"], # Bank id + "preset_info": None, # Preset id + "controllers": { # Optional dictionary of controllers (overrides preset default value) "volume": { # Indexed by controller symbol "value": 96, # Controller value "midi_cc_momentary_switch": 1, # Optional momentary toggle "midi_cc_debounce": 1 # Optional toggle debounce - }, + }, # ... More controllers }, # ... Other parameters - } # ... Other controllers + }, # ... Other controllers }, # ... Other processors - "mixer": { # Dictionary of audio mixer configuration (optional, overrides base value) - "chan_00": { # Indexed by mixer channel / strip (or "main") - "level": 0.800000011920929, # Fader value (optional, overrides base value) - "balance": 0.5, # Balance/pan state (optional, overrides base value) - "mute": 0, # Mute state (optional bitwise flag, overrides base value) b0:state, b1:momentary - "solo": 0, # Solo state (optional bitwise flag, overrides base value) b0:state, b1:momentary - "mono": 0, # Mono state (optional bitwise flag, overrides base value) b0:state, b1:momentary - "phase": 0, # Phase reverse state (optional bitwise flag, overrides base value) b0:state, b1:momentary - }, # ... Other mixer strips - "midi_learn": { # Mixer MIDI learn - "chan,cc": "graph_path", # graph_path [strip index, param symbol] mapped by "midi chan, midi cc" - } # ... Other MIDI learn configs - }, - "chains": { # Dictionary of chain specific ZS3 config indexed by chain ID - "01": { # Chain 01 + "chains": { # Dictionary of chain specific ZS3 config indexed by chain id + "1": { # Chain 1 + "restore": True, # Optional False to omit chain from restore (default: True) + "midi_learn": { # Dictionary of MIDI CC binding, indexed by 16-bit encoded [MIDI chan << 8 | CC] + "267": [ # List of control bindings to this CC + [ + 1, # Processor id + "volume" # Controller symbol + ], # ... Other controllers + ], # ... Other bindings + }, + "midi_chan": 0, # Override chain MIDI channel "midi_in": ["MIDI IN"], # List of chain jack MIDI input sources (may include aliases) "midi_out": ["MIDI OUT"],# List of chain jack MIDI output destinations (may include aliases) - "midi_thru": False, # True to allow MIDI pass-through when MIDI chain empty "audio_in": [0, 1], # List of index of physical input indicies or zynmixer:send - "audio_out": [0, "system:playback"], # Targets for chain routing: Chain id | jackport regex | [procid, input port name] - "audio_thru": False, # True to allow audio pass-through when audio chain empty - "note_low": 0, # Lowest MIDI note chain responds to - "note_high": 127, # Higheset MIDI note chain responds to - "transpose_octave": 0, # Octaves to transpose chain MIDI - "transpose_semitone": 0, # Semitones to transpose chain MIDI - "midi_cc": { # Dictionary of MIDI mapping, indexed by CC number - "7": [ # List of controller configs - ["2", "volume"], # Controller configs [proc_id, symbol] - ], # ... Other controllers mapped to this CC - } # ... Other CC mapped to this chain + "audio_out": [0, "system:playback"], # Lis of targets for chain routing: Chain id | jackport regex | [procid, input port name] + "note_low": 0, # Optional lowest MIDI note chain responds to + "note_high": 127, # Optional higheset MIDI note chain responds to + "transpose_octave": 0, # Optional octaves to transpose chain MIDI + "transpose_semitone": 0, # Optional semitones to transpose chain MIDI }, }, # ... Other chains "midi_capture": { # Dictionary of midi input configuration mapped by port input uid - "ttymidi:MIDI_in": { - "zmip_input_mode": 1, # 1 if active chain mode enabled (stage mode), 0 for multitimbral - "disable_ctrldev": 0, # 1 to disable loading of controller device driver + "'ttymidi:MIDI_in'": { + "zmip_input_mode": True, # True if active chain mode enabled (stage mode), False for multitimbral + "zmip_system": True, # True to enable MIDI system messages + "zmip_system_rt": True, # True to enable MIDI realtime system messages + "disable_ctrldev": False, # True to disable loading of controller device driver + "ctrldev_driver": "zynthian_ctrldev_launchkey_mini_mk3", # Name of controoler device driver "routed_chains": [], # List of chain zmops this input is routed to - "audio_in": [0, 1], # List of audio inputs, e.g. for aubio (optional) - "midi_cc": { # Map of MIDI CC mapping, indexed by MIDI channel - "0": { # Map of controls, indexed by CC number - "121": [ # List of controller configs - [1, "volume"], # Controller config [proc_id, symbol] - ], # ... Other controllers - }, # ... Other CCs - } # ... Other MIDI channels - } + "audio_in": [0, 1], # Optional list of audio inputs, e.g. for aubio + "midi_learn": { # Dictionary of global/absolute MIDI CC binding, indexed by 16-bit encoded (MIDI chan, CC) + "11": [ # List of control bindings to this CC + [ + 1, # Processor id + "volume" # Controller symbol + ] + ], # ... Other controllers + } # ... Other bindings + }, # ... Other devices }, "global": { # Dictionary of global params settable by zs3 indexed by param name "midi_transpose": 0, # Semitones to globally transpose - "zctrl_x": [0, "volume"], # Mapping of x-axis controller [proc_id, symbol] - "zctrl_y": [0, "cutoff"], # Mapping of y-axis controller [proc_id, symbol] + "zctrl_x": [0, "volume"], # Optional mapping of x-axis controller [proc_id, symbol] + "zctrl_y": [0, "cutoff"], # Optional mapping of y-axis controller [proc_id, symbol] + "zynaptik": { # zynaptik configuration + "cvin_volts_octave": 1.0, # CV input volts per octave + "cvin_note0": 0, # CV input note 0 + "cvout_volts_octave": 1.0, # CV output volts per octave + "cvout_note0": 0 # CV output note 0 + }, + "send_clock": [ # List of MIDI output ports to send MIDI clock + "ttymidi:MIDI_out", + "USB:2.2/APC40 mkII OUT 1", + # ... Other ports + ], + "clock_source": -1 # Index of ZMIP acting as MIDI clock source. -1 for internal. } }, "1/2": {}, # ZS3 for channel 1, program change 2 "zs3-1": {}, # Manually saved ZS3 without assigned program change }, # ... Other ZS3 - "engine_config": { # Engine specific configuration (global for all processor instances of engine - "MX": None, # ALSA mixer configuration - "PT": None, # Pianoteq configuration - }, # ... Other engines - "audio_recorder_armed": [0, 3], # List of audio mixer strip indicies armed for multi-track audio recording - "zynseq_riff_b64": "dmVycwAA...", # Binary encoded RIFF data for step sequencer patterns, sequences, etc. - "alsa_mixer": { # Indexed by processor ID - "controllers": { # Dictionary of controllers - "Digital_0": { # Indexed by control symbol - "value": 100 # Controller value - }, # ... Other controllers - } + "last_zs3_id": "zs3-0", # Name of zs3 loaded when snapshot saved + "zynseq": { + "tempo": 120.0, # Default tempo in BPM + "bpb": 4, # Default beats per bar (time signature) + "scene": 0, # Selected scene when saved + "patns": { # Map of patterns, indexed by pattern id + "1": { # Pattern id + "steps": 16, # Steps in pattern + "beats": 4, # Beats in pattern + "scale": 0, # Index of scale to use for this pattern + "tonic": 0, # Root note for scale + "refNote": 51, + "ccnum": [0, 1, ...], # Interpolate CC numbers + "quantize": 0, # Amount to quantize + "swingDiv": 1, # Swing divisor + "swing": 0.0, # Amount of swing to apply to pattern + "humanTime": 0.0, # Variation in timing feel + "humanVel": 0.0, # Variation in velocity feel + "chance": 100, # Probability % of notes within pattern playing + "events": [ # List of events in the pattern + [ # List of event parameters (use list for optimisation) + 0, # Step within pattern + 0.0, # Offset from start of step + 1.0, # Duration of event + 144, # MIDI command + 60, # MIDI value 1 at start of event + 60, # MIDI value 1 at end of event + 100, # MIDI value 2 at start of event + 0, # MIDI value 2 at end of event + 0, # Quantity of stutters + 1, # Duration of each stutter + 100 # Probability % of event triggering + ], # ... Other events + ] + }, # ... Other patterns + }, + "scenes": [ # List of scenes, indexed by scene id + { # First scene + "name": "Scene 1", # Optional scene name + "phrases": [ # List of phrases, indexed by phrase number + { # First phrase + "name": "A", # Scene name + "mode": 4, # Scene play mode + "bpb": 4, # Beats per bar (time signature) override + "tempo": 0, # Tempo override 0=no override + "repeat": 1, # Quantity of repeats + "followAction": 0, # Follow action + "followParam": 0, # Follow action param + "state": 0, # Play state (when snapshot saved) + "sequences": [ # List of sequences in phrase + { + "mode": 4, # Sequence play mode + "group": 0, # Mutex group (0..15, Chain MIDI channel else multi-channel) + "name": "1", # Sequence name + "repeat": 1, # Quantity of repeats + "followAction": 0, # Follow action + "followParam": 0, # Follow action param + "state": 0, # Play state (when snapshot saved) + "tracks": [ # List of tracks in sequence + { + "chan": 0, # MIDI channel + "output": 0, # Jack output + "map": 0, # Key map + "patns": { # Indexed by relative start time within sequence + "0": 3, # Pattern id + # ... Other patterns + } + }, # ... Other tracks + ], + "timebase": [ # List of timebase events + { # Event + "bar": 1, # Measure or bar of event + "tick": 0, # Tick within measure + "type": 0, # Event type, e.g. tempo + "value": 80 # Event value + }, + # ... Other timebase events + ] + }, # ... Other sequences + ], + }, # .. Other phrases + ] + }, # Other scenes + ] }, - "zyngui": { # Optional UI specific configuration - "processors": { # Processor specific config - "1": { # Indexed by processor id - "show_fav_presets": False, # True if presets displayed - "current_screen_index": 8 # Index of last selected controller view page - } - } + "gui": { # Optional GUI configuration + "pinned_chains": 1 # Quantity of chains pinned to right edge (including main mixbus) } }