diff --git a/README.md b/README.md index 651f661..63749d6 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,21 @@ ClyphX provides an extensive list of _Actions_ related to controlling different aspects of Live. -These _Actions_ can be accessed via _X-Triggers_ (Session View Clips, Arrange -View Locators or MIDI Controls). Each _X-Trigger_ can trigger either a single -_Action_ or a list of _Actions_. - As an example, a simple _Action_ might toggle Overdub on/off: ``` -OVER +over ``` A more complex _Action List_ might mute Tracks 1-2, unmute and arm Track 4, turn the 2nd Device on Track 4 on, and launch the selected Clip on track 4: ``` -1-2/MUTE ON; 4/MUTE OFF; 4/ARM ON; 4/DEV2 ON; 4/PLAY +1-2/mute on; 4/mute off; 4/arm on; 4/dev2 on; 4/play ``` +These _Actions_ can be accessed via _X-Triggers_ (Session View Clips, Arrange +View Locators or MIDI Controls). Each _X-Trigger_ can trigger either a single +_Action_ or a list of _Actions_. + **ClyphX** also includes: - A component called **Macrobat** that adds new functionality to Racks in Live diff --git a/src/clyphx/actions/arsenal.py b/src/clyphx/actions/arsenal.py index 8ae97e1..32a2604 100644 --- a/src/clyphx/actions/arsenal.py +++ b/src/clyphx/actions/arsenal.py @@ -135,7 +135,8 @@ def _handle_mode_action(self, script, spec): if mc: adjust_property(mc, 'selected_mode_index', 0, mc.num_modes - 1, spec[1:]) - def _handle_lock_action(self, script, spec): + @staticmethod + def _handle_lock_action(script, spec): # type: (Any, Text) -> None '''Handles toggling the locking of the current track or mode-specific locks. @@ -182,7 +183,8 @@ def _handle_scale_action(self, script, spec, xclip, ident): self._toggle_scale_offset(scl, value) scl._notify_scale_settings() - def _capture_scale_settings(self, script, xclip, ident): + @staticmethod + def _capture_scale_settings(script, xclip, ident): # type: (Any, Clip, Text) -> None '''Captures the current scale type, tonic, in key state, offset and orientation and adds them to the given xclip's name. @@ -195,7 +197,8 @@ def _capture_scale_settings(self, script, xclip, ident): comp.orientation_is_horizontal, ) - def _recall_scale_settings(self, comp, spec): + @staticmethod + def _recall_scale_settings(comp, spec): # type: (Any, Text) -> None '''Recalls previously stored scale settings.''' if len(spec) >= 5: @@ -209,14 +212,15 @@ def _recall_scale_settings(self, comp, spec): # deprecated if len(spec) == 5: value = ['ON'] if spec[4].strip() == 'TRUE' else ['OFF'] - self._toggle_scale_offset(comp, value) + XArsenalActions._toggle_scale_offset(comp, value) else: offset = parse_int(spec[4], None, 0, comp._offsets.num_pages - 1) if offset is not None: comp._offsets.set_page_index(offset) comp._orientation_is_horizontal = spec[5].strip() == 'TRUE' - def _toggle_scale_offset(self, comp, arg): + @staticmethod + def _toggle_scale_offset(comp, arg): # type: (Any, Sequence[Text]) -> None '''Toggles between sequent and 4ths offsets. diff --git a/src/clyphx/actions/clip.py b/src/clyphx/actions/clip.py index c66c97a..bdaa7fb 100644 --- a/src/clyphx/actions/clip.py +++ b/src/clyphx/actions/clip.py @@ -26,7 +26,7 @@ from ..core.legacy import _DispatchCommand from ..core.xcomponent import XComponent -from ..core.live import Clip +from ..core.live import Clip, Conversions from .clip_env_capture import XClipEnvCapture from .clip_notes import ClipNotesMixin from ..consts import (CLIP_GRID_STATES, R_QNTZ_STATES, @@ -49,7 +49,6 @@ def __init__(self, parent): def dispatch_actions(self, cmd): # type: (_DispatchCommand) -> None from .consts import CLIP_ACTIONS - for scmd in cmd: # TODO: compare with dispatch_device_actions if scmd.track in self._parent.song().tracks: @@ -61,17 +60,56 @@ def dispatch_actions(self, cmd): clip_args = action[1] if clip_args and clip_args.split()[0] in CLIP_ACTIONS: func = CLIP_ACTIONS[clip_args.split()[0]] - func(self, action[0], scmd.track, scmd.xclip, scmd.ident, + func(self, action[0], scmd.track, scmd.xclip, clip_args.replace(clip_args.split()[0], '')) elif clip_args and clip_args.split()[0].startswith('NOTES'): self.dispatch_clip_note_action(action[0], cmd.args) elif cmd.action_name.startswith('CLIP'): - self.set_clip_on_off(action[0], scmd.track, scmd.xclip, None, cmd.args) + self.set_clip_on_off(action[0], scmd.track, scmd.xclip, cmd.args) def get_clip_to_operate_on(self, track, action_name, args): # type: (Track, Text, Text) -> Tuple[Optional[Clip], Text] '''Get clip to operate on and action to perform with args.''' + try: + # TODO: add args to parsing + parsed = self._parent.parse_obj('clip', action_name) + except Exception: + try: + parsed, args = self._parse_split_name(action_name, args) + except Exception as e: + log.error('Failed to parse clip: %r', e) + return + clip = None + if 'name' in parsed: # CLIP"Name" + clip = self.get_clip_by_name(track, parsed['name']) + elif 'sel' in parsed: # CLIPSEL + if self.application().view.is_view_visible('Arranger'): + clip = self.song().view.detail_clip + else: + slot_idx = None + if not parsed: # CLIP + if track.playing_slot_index >= 0: + slot_idx = track.playing_slot_index + elif 'pos' in parsed: # CLIPx + slot_idx = int(parsed['pos']) - 1 + + slot_idx = self.sel_scene if slot_idx is None else slot_idx + if track.clip_slots[slot_idx].has_clip: + clip = track.clip_slots[slot_idx].clip + return clip, args + + @staticmethod + def get_clip_by_name(track, name): + # TODO: remove uppers + name = name.upper() + for slot in track.clip_slots: + if slot.has_clip and slot.clip.name.upper() == name: + return slot.clip + + @staticmethod + def _parse_split_name(action_name, args): + clip_name = None clip_args = args if 'CLIP"' in action_name: clip_name = action_name[action_name.index('"')+1:] @@ -80,51 +118,32 @@ def get_clip_to_operate_on(self, track, action_name, args): clip_args = args[args.index('"')+1:].strip() if '"' in clip_name: clip_name = clip_name[0:clip_name.index('"')] - for slot in track.clip_slots: - if slot.has_clip and slot.clip.name.upper() == clip_name: - clip = slot.clip - break - else: - sel_slot_idx = list(self.song().scenes).index(self.song().view.selected_scene) - slot_idx = sel_slot_idx - if action_name == 'CLIP': - if track.playing_slot_index >= 0: - slot_idx = track.playing_slot_index - elif action_name == 'CLIPSEL': - if self.application().view.is_view_visible('Arranger'): - clip = self.song().view.detail_clip - else: - try: - slot_idx = int(action_name.replace('CLIP', ''))-1 - except: - slot_idx = sel_slot_idx - if clip != None and track.clip_slots[slot_idx].has_clip: - clip = track.clip_slots[slot_idx].clip - log.debug('get_clip_to_operate_on -> clip=%s, clip args=%s', - clip.name if clip else 'None', clip_args) - return (clip, clip_args) - def set_clip_name(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + if clip_name is not None: + return {'name': clip_name}, clip_args + raise ValueError('{} {}'.format(action_name, args)) + + def set_clip_name(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Set clip's name.''' args = args.strip() if args: clip.name = args - def set_clip_on_off(self, clip, track, xclip, ident, value=None): - # type: (Clip, None, None, None, Optional[Text]) -> None + def set_clip_on_off(self, clip, track, xclip, value=None): + # type: (Clip, None, None, Optional[Text]) -> None '''Toggles or turns clip on/off.''' # XXX: reversed value, not fallback clip.muted = not KEYWORDS.get(value, clip.muted) - def set_warp(self, clip, track, xclip, ident, value=None): - # type: (Clip, None, None, None, Optional[Text]) -> None + def set_warp(self, clip, track, xclip, value=None): + # type: (Clip, None, None, Optional[Text]) -> None '''Toggles or turns clip warp on/off.''' if clip.is_audio_clip: switch(clip, 'warping', value) - def adjust_time_signature(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def adjust_time_signature(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Adjust clip's time signature.''' if '/' in args: try: @@ -134,13 +153,13 @@ def adjust_time_signature(self, clip, track, xclip, ident, args): except Exception as e: log.error('Failed to adjust time signature: %r', e) - def adjust_detune(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def adjust_detune(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Adjust/set audio clip detune.''' if clip.is_audio_clip: args = args.strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args) + factor = self.get_adjustment_factor(args) clip.pitch_fine = clip.pitch_fine + factor else: try: @@ -148,14 +167,14 @@ def adjust_detune(self, clip, track, xclip, ident, args): except Exception as e: log.error('Failed to adjust detune: %r', e) - def adjust_transpose(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def adjust_transpose(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Adjust audio or midi clip transpose, also set audio clip transpose. ''' args = args.strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args) + factor = self.get_adjustment_factor(args) if clip.is_audio_clip: clip.pitch_coarse = max(-48, min(48, (clip.pitch_coarse + factor))) elif clip.is_midi_clip: @@ -185,15 +204,15 @@ def do_note_pitch_adjustment(self, clip, factor): if edited_notes: self.write_all_notes(clip, edited_notes, note_data['other_notes']) - def adjust_gain(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def adjust_gain(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Adjust/set clip gain for Live 9. For settings, range is 0 - 127. ''' if clip.is_audio_clip: args = args.strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args, True) + factor = self.get_adjustment_factor(args, True) clip.gain = max(0.0, min(1.0, (clip.gain + factor * float(1.0 / 127.0)))) else: try: @@ -201,14 +220,14 @@ def adjust_gain(self, clip, track, xclip, ident, args): except Exception as e: log.error('Failed to adjust gain: %r', e) - def adjust_start(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def adjust_start(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Adjust/set clip start exclusively for Live 9. In Live 8, same as adjust_loop_start. ''' args = args.strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args, True) + factor = self.get_adjustment_factor(args, True) if clip.looping: clip.start_marker = max(0.0, min(clip.end_marker - factor, (clip.start_marker + factor))) else: @@ -222,12 +241,12 @@ def adjust_start(self, clip, track, xclip, ident, args): except Exception as e: log.error('Failed to adjust start: %r', e) - def adjust_loop_start(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def adjust_loop_start(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Adjust/set clip loop start if loop is on or clip start otherwise.''' args = args.strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args, True) + factor = self.get_adjustment_factor(args, True) clip.loop_start = max(0.0, min(clip.loop_end - factor, (clip.loop_start + factor))) else: try: @@ -235,14 +254,14 @@ def adjust_loop_start(self, clip, track, xclip, ident, args): except Exception as e: log.error('Failed to adjust loop start: %r', e) - def adjust_end(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def adjust_end(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Adjust/set clip end exclusively for Live 9. In Live 8, same as adjust_loop_end. ''' args = args.strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args, True) + factor = self.get_adjustment_factor(args, True) if clip.looping: clip.end_marker = max((clip.start_marker - factor), (clip.end_marker + factor)) @@ -258,13 +277,13 @@ def adjust_end(self, clip, track, xclip, ident, args): except Exception as e: log.error('Failed to adjust end: %r', e) - def adjust_loop_end(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def adjust_loop_end(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Adjust/set clip loop end if loop is on or close end otherwise. ''' args = args.strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args, True) + factor = self.get_adjustment_factor(args, True) clip.loop_end = max((clip.loop_start - factor), (clip.loop_end + factor)) else: try: @@ -272,8 +291,8 @@ def adjust_loop_end(self, clip, track, xclip, ident, args): except Exception as e: log.error('Failed to adjust loop end: %r', e) - def adjust_cue_point(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def adjust_cue_point(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Adjust clip's start point and fire (also stores cue point if not specified). Will not fire xclip itself as this causes a loop. ''' @@ -281,7 +300,7 @@ def adjust_cue_point(self, clip, track, xclip, ident, args): if args: args = args.strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args, True) + factor = self.get_adjustment_factor(args, True) args = clip.loop_start + factor try: clip.loop_start = float(args) @@ -297,8 +316,8 @@ def adjust_cue_point(self, clip, track, xclip, ident, args): if isinstance(xclip, Clip): xclip.name = '{} {}'.format(xclip.name.strip(), clip.loop_start) - def adjust_warp_mode(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def adjust_warp_mode(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Adjusts the warp mode of the clip. This cannot be applied if the warp mode is currently rex (5). ''' @@ -307,7 +326,7 @@ def adjust_warp_mode(self, clip, track, xclip, ident, args): if args in WARP_MODES: clip.warp_mode = WARP_MODES[args] elif args in ('<', '>'): - factor = self._parent.get_adjustment_factor(args) + factor = self.get_adjustment_factor(args) new_mode = clip.warp_mode + factor if new_mode == 5 and '>' in args: new_mode = 6 @@ -316,24 +335,24 @@ def adjust_warp_mode(self, clip, track, xclip, ident, args): if 0 <= new_mode < 7 and new_mode != 5: clip.warp_mode = new_mode - def adjust_grid_quantization(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def adjust_grid_quantization(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Adjusts clip grid quantization.''' args = args.strip() if args in CLIP_GRID_STATES: clip.view.grid_quantization = CLIP_GRID_STATES[args] - def set_triplet_grid(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def set_triplet_grid(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Toggles or turns triplet grid on or off.''' switch(clip.view, 'grid_is_triplet', args) - def capture_to_envelope(self, clip, track, xclip, ident, args): - # type: (Clip, Any, None, None, Text) -> None + def capture_to_envelope(self, clip, track, xclip, args): + # type: (Clip, Any, None, Text) -> None self._env_capture.capture(clip, track, args) - def insert_envelope(self, clip, track, xclip, ident, args): - # type: (Clip, Any, None, None, Text) -> None + def insert_envelope(self, clip, track, xclip, args): + # type: (Clip, Any, None, Text) -> None '''Inserts an envelope for the given parameter into the clip. This doesn't apply to quantized parameters. @@ -411,8 +430,8 @@ def _perform_envelope_insertion(self, clip, param, env_type, env_range): else: env.insert_step(beat, 1.0, env_range[0]) - def clear_envelope(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def clear_envelope(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Clears the envelope of the specified param or all envelopes from the given clip. ''' @@ -423,8 +442,8 @@ def clear_envelope(self, clip, track, xclip, ident, args): else: clip.clear_all_envelopes() - def show_envelope(self, clip, track, xclip, ident, args): - # type: (Clip, Any, None, None, Text) -> None + def show_envelope(self, clip, track, xclip, args): + # type: (Clip, Any, None, Text) -> None '''Shows the clip's envelope view and a particular envelope if specified. Requires 9.1 or later. ''' @@ -460,20 +479,20 @@ def _get_envelope_parameter(self, track, args): param_array = dev_array[1].strip().split() param = None if len(param_array) > 1: - param = self._parent.device_actions.get_banked_parameter( - dev_array[0], param_array[0], param_array[1]) + param = self._parent.device_actions.get_bank_param( + dev_array[0], param_array[1], param_array[0]) else: - param = self._parent.device_actions.get_bob_parameter( + param = self._parent.device_actions.get_bank_param( dev_array[0], param_array[0]) return param - def hide_envelopes(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, None) -> None + def hide_envelopes(self, clip, track, xclip, args): + # type: (Clip, None, None, None) -> None '''Hides the clip's envelope view.''' clip.view.hide_envelope() - def quantize(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None + def quantize(self, clip, track, xclip, args): + # type: (Clip, None, None, Text) -> None '''Quantizes notes or warp markers to the given quantization value, at the (optional) given strength and with the (optional) percentage of swing. Can optionally be applied to specific notes @@ -519,10 +538,10 @@ def quantize(self, clip, track, xclip, ident, args): clip.quantize_pitch(note, rate_to_apply, strength) self.song().swing_amount = current_swing - def duplicate_clip_content(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, None) -> None - '''Duplicates all the content in a MIDI clip and doubles loop length. - Will also zoom out to show entire loop if loop is on. + def duplicate_clip_content(self, clip, track, xclip, args): + # type: (Clip, None, None, None) -> None + '''Duplicates all the content in a MIDI clip and doubles loop + length. Will also zoom out to show entire loop if loop is on. ''' if clip.is_midi_clip: try: @@ -530,26 +549,26 @@ def duplicate_clip_content(self, clip, track, xclip, ident, args): except Exception as e: log.error('Failed to duplicate clip content: %r', e) - def delete_clip(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, None) -> None + def delete_clip(self, clip, track, xclip, args): + # type: (Clip, None, None, None) -> None '''Deletes the given clip.''' clip.canonical_parent.delete_clip() - def duplicate_clip(self, clip, track, xclip, ident, args): - # type: (Clip, Track, None, None, None) -> None - '''Duplicates the given clip. This will overwrite clips if any exist - in the slots used for duplication. + def duplicate_clip(self, clip, track, xclip, args): + # type: (Clip, Track, None, None) -> None + '''Duplicates the given clip. This will overwrite clips if any + exist in the slots used for duplication. ''' try: track.duplicate_clip_slot(list(track.clip_slots).index(clip.canonical_parent)) except Exception as e: log.error('Failed to duplicate clip: %r', e) - def chop_clip(self, clip, track, xclip, ident, args): - # type: (Clip, Track, None, None, Text) -> None - '''Duplicates the clip the number of times specified and sets evenly - distributed start points across all duplicates. This will overwrite - clips if any exist in the slots used for duplication. + def chop_clip(self, clip, track, xclip, args): + # type: (Clip, Track, None, Text) -> None + '''Duplicates the clip the number of times specified and sets + evenly distributed start points across all duplicates. This will + overwrite clips if any exist in the slots used for duplication. ''' args = args.strip() num_chops = 8 @@ -572,10 +591,10 @@ def chop_clip(self, clip, track, xclip, ident, args): except Exception as e: log.error('Failed to chop clip: %r', e) - def split_clip(self, clip, track, xclip, ident, args): - # type: (Clip, Track, None, None, Text) -> None - '''Duplicates the clip and sets each duplicate to have the length - specified in args. This will overwrite clips if any exist in the + def split_clip(self, clip, track, xclip, args): + # type: (Clip, Track, None, Text) -> None + '''Duplicates the clip and sets each duplicate to have the + length specified in args. This will overwrite any clip in the slots used for duplication. ''' try: @@ -603,6 +622,13 @@ def split_clip(self, clip, track, xclip, ident, args): except Exception as e: log.error('Failed to split clip: %r', e) + def crop(self, clip): + '''Crops the clip. + + If looped, removes the region outside of the loop. Otherwise, + removes the region outside the start and end markers.''' + clip.crop() + def get_clip_stats(self, clip): # type: (Clip) -> Dict[Text, Any] '''Get real length and end of looping clip.''' @@ -617,11 +643,36 @@ def get_clip_stats(self, clip): loop_length = loop_length, ) + def is_convertible_to_midi(self, clip): + return (clip.is_audio_clip + and Conversions.is_convertible_to_midi(self.song(), clip)) + + def to_midi(self, clip, track, xclip, args): + # type: (Clip) -> None + if not self.is_convertible_to_midi(clip): + log.info('to_midi not implemented') + + # (Live 10 only) Added CLIP CROP Action for cropping Clips. + + def to_drum_rack(self, clip, track, xclip, args): + '''Creates a new track with a Drum Rack with a Simpler on the + first pad with the specified audio clip.''' + if self.is_convertible_to_midi(clip): + Conversions.create_drum_rack_from_audio_clip(self.song(), clip) + else: + log.warning('Clip not convertible to MIDI') + + def to_simpler(self, clip, track, xclip, args): + '''Creates a new MIDI track with a Simpler including the + specified audio clip.''' + if self.is_convertible_to_midi(clip): + Conversions.create_midi_track_with_simpler(self.song(), clip) + # TODO: make Mixin together with transport functions # TODO: check XGlobalActions.do_loop_action and device_looper # region CLIP LOOP ACTIONS - def do_clip_loop_action(self, clip, track, xclip, ident, args): - # type: (Clip, Track, Clip, None, Text) -> None + def do_clip_loop_action(self, clip, track, xclip, args): + # type: (Clip, Track, Clip, Text) -> None '''Handle clip loop actions.''' args = args.strip() if not args or args.upper() in KEYWORDS: @@ -629,11 +680,11 @@ def do_clip_loop_action(self, clip, track, xclip, ident, args): else: if args.startswith('START'): self.adjust_loop_start( - clip, track, xclip, ident, args.replace('START', '', 1).strip() + clip, track, xclip, args.replace('START', '', 1).strip() ) elif args.startswith('END'): self.adjust_loop_end( - clip, track, xclip, ident, args.replace('END', '', 1).strip() + clip, track, xclip, args.replace('END', '', 1).strip() ) elif args == 'SHOW': clip.view.show_loop() @@ -652,7 +703,7 @@ def do_clip_loop_action(self, clip, track, xclip, ident, args): new_end = ((clip.loop_end - clip_stats['loop_length']) + (clip_stats['loop_length'] * float(args[1:]))) except Exception as e: - log.error('Failed to do clip action: %r', e) + log.error('Failed to do clip loop action: %r', e) else: self.do_loop_set(clip, args, clip_stats) return @@ -670,7 +721,7 @@ def move_clip_loop_by_factor(self, clip, args, clip_stats): if args == '<': factor = -(factor) if len(args) > 1: - factor = self._parent.get_adjustment_factor(args, True) + factor = self.get_adjustment_factor(args, True) new_end = clip.loop_end + factor new_start = clip.loop_start + factor if new_start < 0.0: @@ -680,8 +731,8 @@ def move_clip_loop_by_factor(self, clip, args, clip_stats): def do_loop_set(self, clip, args, clip_stats): # type: (Clip, Text, Dict[Text, Any]) -> None - '''Set loop length and (if clip is playing) position, quantizes to 1/4 - by default or bar if specified. + '''Set loop length and (if clip is playing) position, quantizes + to 1/4 by default or bar if specified. ''' try: qntz = False @@ -705,8 +756,8 @@ def do_loop_set(self, clip, args, clip_stats): def set_new_loop_position(self, clip, new_start, new_end, clip_stats): # type: (Clip, float, float, Dict[Text, Any]) -> None - '''For use with other clip loop actions, ensures that loop settings - are within range and applies in correct order. + '''For use with other clip loop actions, ensures that loop + settings are within range and applies in correct order. ''' if new_end <= clip_stats['real_end'] and new_start >= 0: # FIXME: same values diff --git a/src/clyphx/actions/clip_env_capture.py b/src/clyphx/actions/clip_env_capture.py index d0b7af1..8574dcc 100644 --- a/src/clyphx/actions/clip_env_capture.py +++ b/src/clyphx/actions/clip_env_capture.py @@ -83,13 +83,15 @@ def _capture_nested_devices(self, clip, rack): if device.can_have_chains and device.chains: self._capture_nested_devices(clip, device) - def _insert_envelope(self, clip, param): + @staticmethod + def _insert_envelope(clip, param): # type: (Clip, DeviceParameter) -> None env = clip.automation_envelope(param) if env: env.insert_step(clip.loop_start, 0.0, param.value) - def _get_device_range(self, args, track): + @staticmethod + def _get_device_range(args, track): # type: (Text, Track) -> Optional[Tuple[int, int]] '''Returns range of devices to capture.''' dev_args = args.replace('MIX', '') diff --git a/src/clyphx/actions/clip_notes.py b/src/clyphx/actions/clip_notes.py index 50983f4..a47229e 100644 --- a/src/clyphx/actions/clip_notes.py +++ b/src/clyphx/actions/clip_notes.py @@ -16,11 +16,11 @@ from __future__ import absolute_import, unicode_literals from builtins import object, dict, range -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple, List, Text import logging if TYPE_CHECKING: - from typing import Any, Union, Optional, Text, Dict, List, Tuple + from typing import Any, Union, Optional, Dict, Tuple Note = Tuple[int, float, Any, Any, bool] NoteActionSignature = Clip, Text, List[Note], List[Note] @@ -28,6 +28,12 @@ from ..consts import NOTE_NAMES, OCTAVE_NAMES from ..core.live import Clip, get_random_int +# from ..core.models import Note +# from ..core.parse import Pitch +# NoteData = NamedTuple('NoteData', [('notes_to_edit', List[Note]), +# ('other_notes', List[Note]), +# ('args', Text)]) # TODO: Tuple[Text] + class ClipNotesMixin(object): @@ -81,7 +87,8 @@ def get_notes_to_operate_on(self, clip, args=None): args = new_args, ) - def get_pos_range(self, clip, string): + @staticmethod + def get_pos_range(clip, string): # type: (Clip, Text) -> Tuple[float, float] '''Get note position or range to operate on.''' pos_range = (clip.loop_start, clip.loop_end) @@ -124,7 +131,8 @@ def get_note_range(self, string): pass return note_range - def get_note_range_from_string(self, string): + @staticmethod + def get_note_range_from_string(string): # type: (Text) -> Tuple[int, int] '''Returns a note range (specified in ints) from string. ''' @@ -138,7 +146,8 @@ def get_note_range_from_string(self, string): return (start, end) raise ValueError("Invalid range note: '{}'".format(string)) - def get_note_name_from_string(self, string): + @staticmethod + def get_note_name_from_string(string): # type: (Text) -> Text '''Get the first note name specified in the given string.''' if len(string) >= 2: @@ -150,7 +159,8 @@ def get_note_name_from_string(self, string): return result raise ValueError("'{}' does not contain a note".format(string)) - def string_to_note(self, string): + @staticmethod + def string_to_note(string): # type: (Text) -> Any '''Get note value from string.''' base_note = None @@ -171,7 +181,8 @@ def string_to_note(self, string): raise ValueError("Invalid note: '{}'".format(string)) - def write_all_notes(self, clip, edited_notes, other_notes): + @staticmethod + def write_all_notes(clip, edited_notes, other_notes): # type: (Clip, List[Note], Any) -> None '''Writes new notes to clip.''' edited_notes.extend(other_notes) @@ -199,7 +210,7 @@ def do_note_gate_adjustment(self, clip, args, notes_to_edit, other_notes): # type: (NoteActionSignature) -> None '''Adjust note gate.''' edited_notes = [] - factor = self._parent.get_adjustment_factor(args.split()[1], True) + factor = self.get_adjustment_factor(args.split()[1], True) for n in notes_to_edit: new_gate = n[2] + (factor * 0.03125) if n[1] + new_gate > clip.loop_end or new_gate < 0.03125: @@ -214,7 +225,7 @@ def do_note_nudge_adjustment(self, clip, args, notes_to_edit, other_notes): # type: (NoteActionSignature) -> None '''Adjust note position.''' edited_notes = [] - factor = self._parent.get_adjustment_factor(args.split()[1], True) + factor = self.get_adjustment_factor(args.split()[1], True) for n in notes_to_edit: new_pos = n[1] + (factor * 0.03125) if n[2] + new_pos > clip.loop_end or new_pos < 0.0: @@ -236,7 +247,7 @@ def do_note_velo_adjustment(self, clip, args, notes_to_edit, other_notes): # FIXME: get_random_int edited_notes.append((n[0], n[1], n[2], get_random_int(64, 64), n[4])) elif args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args) + factor = self.get_adjustment_factor(args) new_velo = n[3] + factor if 0 <= new_velo < 128: edited_notes.append((n[0], n[1], n[2], new_velo, n[4])) diff --git a/src/clyphx/actions/consts.py b/src/clyphx/actions/consts.py index 591e965..c9026eb 100644 --- a/src/clyphx/actions/consts.py +++ b/src/clyphx/actions/consts.py @@ -87,6 +87,12 @@ MAKE_DEV_DOC = XGlobalActions.make_instant_mapping_docs, ) # type: Dict[Text, Callable] +SCENE_ACIONS = dict( + ADD = XGlobalActions.create_scene, + DEL = XGlobalActions.delete_scene, + DUPE = XGlobalActions.duplicate_scene, +) + TRACK_ACTIONS = dict( ARM = XTrackActions.set_arm, MUTE = XTrackActions.set_mute, @@ -137,12 +143,16 @@ DEL = XClipActions.delete_clip, DUPE = XClipActions.duplicate_clip, CHOP = XClipActions.chop_clip, + CROP = XClipActions.crop, SPLIT = XClipActions.split_clip, WARPMODE = XClipActions.adjust_warp_mode, LOOP = XClipActions.do_clip_loop_action, SIG = XClipActions.adjust_time_signature, WARP = XClipActions.set_warp, NAME = XClipActions.set_clip_name, + # TOMIDI = XClipActions.to_midi, + TODR = XClipActions.to_drum_rack, + TOSIMP = XClipActions.to_simpler, ) # type: Dict[Text, Callable] CLIP_NOTE_ACTIONS_CMD = dict([ diff --git a/src/clyphx/actions/control_surface.py b/src/clyphx/actions/control_surface.py index 2a7f4a9..fdcf5be 100644 --- a/src/clyphx/actions/control_surface.py +++ b/src/clyphx/actions/control_surface.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with ClyphX. If not, see . -from __future__ import with_statement, absolute_import, unicode_literals +from __future__ import absolute_import, unicode_literals from builtins import super, dict, range from functools import partial from typing import TYPE_CHECKING @@ -152,11 +152,11 @@ def _handle_push2_init(self, index): def dispatchers(self): return dict( SURFACE = self.dispatch_cs_action, - CS = self.dispatch_cs_action, + CS = self.dispatch_cs_action, ARSENAL = self.dispatch_arsenal_action, - PUSH = self.dispatch_push_action, - PTX = self.dispatch_pxt_action, - MTX = self.dispatch_mxt_action, + PUSH = self.dispatch_push_action, + PTX = self.dispatch_pxt_action, + MTX = self.dispatch_mxt_action, ) # TODO: normalize dispatching @@ -176,37 +176,25 @@ def dispatch_action(self, cmd): def dispatch_cs_action(self, script, xclip, ident, args): # type: (Any, Clip, Text, Text) -> None '''Dispatch appropriate control surface actions.''' - if 'METRO ' in args and 'metro' in self._scripts[script]: - self.handle_visual_metro(self._scripts[script], args) - elif 'RINGLINK ' in args and self._scripts[script]['session']: - self.handle_ring_link( - self._scripts[script]['session'], script, args[9:] - ) - elif 'RING ' in args and self._scripts[script]['session']: - self.handle_session_offset(script, - self._scripts[script]['session'], - args[5:]) - elif ('COLORS ' in args and - self._scripts[script]['session'] and - self._scripts[script]['color']): - self.handle_session_colors(self._scripts[script]['session'], - self._scripts[script]['color'], - args[7:]) - elif 'DEV LOCK' in args and self._scripts[script]['device']: - self._scripts[script]['device'].canonical_parent.toggle_lock() - elif 'BANK ' in args and self._scripts[script]['mixer']: + _script = self._scripts[script] + + if 'METRO ' in args and 'metro' in _script: + self.handle_visual_metro(_script, args) + elif 'RINGLINK ' in args and _script['session']: + self.handle_ring_link(_script['session'], script, args[9:]) + elif 'RING ' in args and _script['session']: + self.handle_session_offset(script, _script['session'], args[5:]) + elif ('COLORS ' in args and _script['session'] and _script['color']): + self.handle_session_colors(_script['session'], _script['color'], args[7:]) + elif 'DEV LOCK' in args and _script['device']: + _script['device'].canonical_parent.toggle_lock() + elif 'BANK ' in args and _script['mixer']: self.handle_track_bank( - script, xclip, ident, self._scripts[script]['mixer'], - self._scripts[script]['session'], args[5:] - ) + script, xclip, ident, _script['mixer'], _script['session'], args[5:]) elif 'RPT' in args: - self.handle_note_repeat( - self._scripts[script]['script'], script, args - ) - elif self._scripts[script]['mixer'] and '/' in args[:4]: - self.handle_track_action( - script, self._scripts[script]['mixer'], xclip, ident, args - ) + self.handle_note_repeat(_script['script'], script, args) + elif _script['mixer'] and '/' in args[:4]: + self.handle_track_action(script, _script['mixer'], xclip, ident, args) def dispatch_push_action(self, track, xclip, ident, action, args): # type: (Track, Clip, Text, None, Text) -> None @@ -307,9 +295,10 @@ def handle_track_action(self, script_key, mixer, xclip, ident, args): if (0 <= track_start and track_start < track_end and track_end < len(mixer._channel_strips) + 1): track_list = [] - if self._scripts[script_key]['name'] == 'PUSH': - offset, _ = self._push_actions.get_session_offsets(self._scripts[script_key]['session']) - tracks_to_use = self._scripts[script_key]['session'].tracks_to_use() + _script = self._scripts[script_key] + if _script['name'] == 'PUSH': + offset, _ = self._push_actions.get_session_offsets(_script['session']) + tracks_to_use = _script['session'].tracks_to_use() else: offset = mixer._track_offset tracks_to_use = mixer.tracks_to_use() @@ -390,15 +379,14 @@ def _parse_ring_spec(self, spec_id, arg_string, default_index, list_to_search): specification that was parsed. ''' index = default_index - arg_array = arg_string.split() - for a in arg_array: + for a in arg_string.split(): if a.startswith(spec_id): if a[1].isdigit(): index = int(a.strip(spec_id)) - 1 arg_string = arg_string.replace(a, '', 1).strip() break elif a[1] in ('<', '>'): - index += self._parent.get_adjustment_factor(a.strip(spec_id)) + index += self.get_adjustment_factor(a.strip(spec_id)) arg_string = arg_string.replace(a, '', 1).strip() break elif a[1] == '"': @@ -419,9 +407,10 @@ def handle_ring_link(self, session, script_index, args): '''Handles linking/unliking session offsets to the selected track or scene with centering if specified. ''' - self._scripts[script_index]['track_link'] = args == 'T' or 'T ' in args or ' T' in args - self._scripts[script_index]['scene_link'] = 'S' in args - self._scripts[script_index]['centered_link'] = 'CENTER' in args + script = self._scripts[script_index] + script['track_link'] = args == 'T' or 'T ' in args or ' T' in args + script['scene_link'] = 'S' in args + script['centered_link'] = 'CENTER' in args def handle_session_colors(self, session, colors, args): # type: (Any, Any, Text) -> None @@ -449,21 +438,20 @@ def handle_visual_metro(self, script, args): This is a specialized version for L9 that uses component guard to avoid dependency issues. ''' - if 'ON' in args and not script['metro']['component']: + metro = script['metro'] + if 'ON' in args and not metro['component']: with self._parent.component_guard(): - m = VisualMetro(self._parent, - script['metro']['controls'], - script['metro']['override']) - script['metro']['component'] = m - elif 'OFF' in args and script['metro']['component']: - script['metro']['component'].disconnect() - script['metro']['component'] = None + m = VisualMetro(self._parent, metro['controls'], metro['override']) + metro['component'] = m + elif 'OFF' in args and metro['component']: + metro['component'].disconnect() + metro['component'] = None def on_selected_track_changed(self): '''Moves the track offset of all track linked surfaces to the selected track with centering if specified. ''' - trk = self.song().view.selected_track + trk = self.sel_track if trk in self.song().tracks: trk_id = list(self.song().visible_tracks).index(trk) for k, v in self._scripts.items(): @@ -496,7 +484,7 @@ def on_selected_scene_changed(self): '''Moves the scene offset of all scene linked surfaces to the selected scene with centering if specified. ''' - scn_id = list(self.song().scenes).index(self.song().view.selected_scene) + scn_id = self.sel_scene for k, v in self._scripts.items(): if v['scene_link']: new_scn_id = scn_id diff --git a/src/clyphx/actions/device.py b/src/clyphx/actions/device.py index 9127a3b..27c2c4b 100644 --- a/src/clyphx/actions/device.py +++ b/src/clyphx/actions/device.py @@ -5,7 +5,7 @@ # terms of the GNU Lesser General Public License as published by the Free # Software Foundation, either version 2.1 of the License, or (at your option) # any later version. -# +## # ClyphX 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 Lesser General Public License for @@ -15,22 +15,20 @@ # along with ClyphX. If not, see . from __future__ import absolute_import, unicode_literals -from builtins import super, dict, range +from builtins import super, dict, range, list from typing import TYPE_CHECKING import logging if TYPE_CHECKING: - from typing import Any, Sequence, Optional, Text, List, Dict + from typing import Any, Sequence, Optional, Text, List, Dict, Callable from ..core.live import Device, DeviceParameter, Track from ..core.legacy import _DispatchCommand, _SingleDispatch -from _Generic.Devices import ( - number_of_parameter_banks, get_parameter_by_name, - DEVICE_DICT, DEVICE_BOB_DICT, -) +from _Generic.Devices import get_parameter_by_name +from ..core.exceptions import InvalidAction, InvalidParam from ..core.xcomponent import XComponent from ..core.live import Clip, get_random_int -from ..consts import switch +from ..consts import switch, DEVICE_BANKS from .device_looper import LooperMixin log = logging.getLogger(__name__) @@ -60,15 +58,19 @@ def dispatch_device_actions(self, cmd): device_action = self._parent.get_device_to_operate_on(*_args) if device_action[0]: try: + # TODO: substitute devargs with splitargs device_args = device_action[1] + split_args = [x.strip().upper() for x in device_action[1].split() if x.strip()] except IndexError: device_args = None + split_args = None - if device_args and device_args.split()[0] in DEVICE_ACTIONS: - func = DEVICE_ACTIONS[device_args.split()[0]] + if split_args and split_args[0] in DEVICE_ACTIONS: + func = DEVICE_ACTIONS[split_args[0]] + # TODO: pass split_args instead of device_args func(self, device_action[0], scmd.track, scmd.xclip, device_args) - elif device_args and 'CHAIN' in device_args: - self.dispatch_chain_action(device_action[0], device_args) + elif split_args and 'CHAIN' in device_args: + self.dispatch_chain_action(device_action[0], split_args) elif cmd.action_name.startswith('DEV'): self.set_device_on_off(device_action[0], device_args) @@ -76,8 +78,8 @@ def select_device(self, device, track, xclip, args): # type: (Device, Track, None, None, None) -> None '''Select device and bring it and the track it's on into view. ''' - if self.song().view.selected_track != track: - self.song().view.selected_track = track + if self.sel_track != track: + self.sel_track = track self.application().view.show_view('Detail') self.application().view.show_view('Detail/DeviceChain') self.song().view.select_device(device) @@ -95,7 +97,7 @@ def set_all_params(self, device, track, xclip, args): param_values = args.split() if len(param_values) == 8: for i in range(8): - self._parent.do_parameter_adjustment( + self.adjust_param( device.parameters[i + 1], param_values[i].strip() ) @@ -128,9 +130,9 @@ def adjust_bob_param(self, device, track, xclip, args): param = None name_split = args.split() if len(name_split) > 1: - param = self.get_bob_parameter(device, name_split[0]) + param = self.get_bank_param(device, name_split[0]) if param and param.is_enabled: - self._parent.do_parameter_adjustment(param, name_split[-1]) + self.adjust_param(param, name_split[-1]) def adjust_banked_param(self, device, track, xclip, args): # type: (Device, None, None, Text) -> None @@ -138,9 +140,9 @@ def adjust_banked_param(self, device, track, xclip, args): param = None name_split = args.split() if len(name_split) > 2: - param = self.get_banked_parameter(device, name_split[0], name_split[1]) + param = self.get_bank_param(device, name_split[1], name_split[0]) if param and param.is_enabled: - self._parent.do_parameter_adjustment(param, name_split[-1]) + self.adjust_param(param, name_split[-1]) def adjust_chain_selector(self, device, track, xclip, args): # type: (Device, None, None, Text) -> None @@ -148,31 +150,29 @@ def adjust_chain_selector(self, device, track, xclip, args): param = self.get_chain_selector(device) name_split = args.split() if param and param.is_enabled and len(name_split) > 1: - self._parent.do_parameter_adjustment(param, name_split[-1]) + self.adjust_param(param, name_split[-1]) def randomize_params(self, device, track, xclip, args): # type: (Device, None, None, None) -> None '''Randomize device parameters.''' - name = device.name.upper() - if not name.startswith( - ('NK RND', 'NK RST', 'NK CHAIN MIX', 'NK DR', 'NK LEARN', - 'NK RECEIVER', 'NK TRACK', 'NK SIDECHAIN') - ): - for p in device.parameters: - if p and p.is_enabled and not p.is_quantized and p.name != 'Chain Selector': - p.value = (((p.max - p.min) / 127) * get_random_int(0, 128)) + p.min + func = lambda p: (((p.max - p.min) / 127) * get_random_int(0, 128)) + p.min + self._rnr_params(device, func) def reset_params(self, device, track, xclip, args): # type: (Device, None, None, None) -> None '''Reset device parameters.''' - name = device.name.upper() - if not name.startswith( - ('NK RND', 'NK RST', 'NK CHAIN MIX', 'NK DR', 'NK LEARN', - 'NK RECEIVER', 'NK TRACK', 'NK SIDECHAIN') - ): + func = lambda p: p.default_value + self._rnr_params(device, func) + + @staticmethod + def _rnr_params(device, func): + # type: (Device, Callable) -> None + from ..macrobat.consts import RNR_EXCLUDED + + if not device.name.upper().startswith(RNR_EXCLUDED): for p in device.parameters: if p and p.is_enabled and not p.is_quantized and p.name != 'Chain Selector': - p.value = p.default_value + p.value = func(p) def set_device_on_off(self, device, value=None): # type: (Device, Optional[Text]) -> None @@ -182,7 +182,8 @@ def set_device_on_off(self, device, value=None): switch(param, 'value', value) # TODO: break? - def get_chain_selector(self, device): + @staticmethod + def get_chain_selector(device): # type: (Device) -> Optional[DeviceParameter] '''Get rack chain selector param.''' if device.class_name.endswith('GroupDevice'): @@ -191,74 +192,61 @@ def get_chain_selector(self, device): return parameter return None - def get_bob_parameter(self, device, param_string): - # type: (Device, Text) -> Optional[DeviceParameter] - '''Get best-of-bank parameter 1-8 for Live's devices. - - The param string should be composed of 'P' followed by the param - index (like P5). + @staticmethod + def get_bank_param(device, param, bank='B0'): + # type: (Device, str, Optional[str]) -> DeviceParameter + '''Get bank/parameter for Live's devices. + + Args: + param: should be composed of 'P' followed by the param + index (1-8), e.g. 'P5'. + bank: if provided should be composed of 'B' followed by the + bank index (1-8), e.g. 'B2'. Otherwise returns a BoB + (best-of-bank) param. ''' try: - param_bank = DEVICE_BOB_DICT[device.class_name][0] - param_num = int(param_string[1]) - 1 - if 0 <= param_num < 8: - parameter = get_parameter_by_name(device, param_bank[param_num]) - if parameter: - return parameter - except: - return None - - def get_banked_parameter(self, device, bank_string, param_string): - # type: (Device, Text, Text) -> Optional[DeviceParameter] - '''Get bank 1-8/parameter 1-8 for Live's devices. + _param = int(param[1]) - 1 + _bank = int(bank[1]) + name = DEVICE_BANKS[device.class_name][_bank][_param] + # TODO: join with dev_doc + return get_parameter_by_name(device, name) + except Exception as e: + log.error('Failed to get banked param (%s/%s): %r', + bank if _bank else 'BoB', param, e) - :param bank_string: should be composed of 'B' followed by the - bank index (like B2) - :param param_string: should be composed of 'P' followed by the - param index (like P5). - ''' - try: - device_bank = DEVICE_DICT[device.class_name] - bank_num = int(bank_string[1]) - 1 - param_num = int(param_string[1]) - 1 - if 0 <= param_num < 8 and 0 <= bank_num < 8 <= number_of_parameter_banks(device): - param_bank = device_bank[bank_num] - parameter = get_parameter_by_name(device, param_bank[param_num]) - if parameter: - return parameter - except: - pass - return None # region CHAIN ACTIONS def dispatch_chain_action(self, device, args): - # type: (Device, Text) -> None + # type: (Device, List[Text]) -> None '''Handle actions related to device chains.''' if not (self._parent._can_have_nested_devices and device.can_have_chains and device.chains): raise TypeError('This device does not support chain actions.') - arg_list = [x.strip() for x in args.upper().split()] try: - chain_num, action, value = arg_list[0:3] + chain_num, action, value = args[0:3] chain = device.chains[int(chain_num.replace('CHAIN', '')) - 1] if action not in {'MUTE', 'SOLO', 'VOL', 'PAN'}: - raise AttributeError('Invalid device chain action: {}'.format(action)) + raise InvalidAction('Invalid device chain action: {}'.format(action)) elif action in {'VOL', 'PAN'} and device.class_name.startswith('Midi'): - raise AttributeError('Invalid MIDI device chain action: {}'.format(action)) + raise InvalidAction('Invalid MIDI device chain action: {}'.format(action)) except Exception as e: - log.error("Failed to parse chain action args '%s': %r", arg_list, e) + log.error("Failed to parse chain action args '%s': %r", args, e) return chain = device.view.selected_chain if chain: - func, param = dict( - MUTE = (switch, chain.mute), - SOLO = (switch, chain.solo), - VOL = (self._parent.do_parameter_adjustment, chain.mixer_device.volume), - PAN = (self._parent.do_parameter_adjustment, chain.mixer_device.panning), + func, obj, param = dict( + # FIXME + MUTE = (switch, chain, 'mute'), + SOLO = (switch, chain, 'solo'), + VOL = (self._adjust_param, chain.mixer_device, 'volume'), + PAN = (self._adjust_param, chain.mixer_device, 'panning'), ) - func(param, value) + func(obj, param, value) + # FIXME: + def _adjust_param(self, obj, param, value): + self.adjust_param(getattr(obj, param), value) # endregion diff --git a/src/clyphx/actions/device_looper.py b/src/clyphx/actions/device_looper.py index 85e077d..ee2c4cc 100644 --- a/src/clyphx/actions/device_looper.py +++ b/src/clyphx/actions/device_looper.py @@ -2,6 +2,7 @@ from builtins import object from ..consts import LOOPER_STATES, switch +from ..core.exceptions import InvalidParam # TODO: turn into a component? @@ -61,4 +62,4 @@ def set_looper_state(self, value=None): try: self.get_param('State').value = LOOPER_STATES[value.upper()] except KeyError: - raise ValueError("'{}' is not a looper state".format(value)) + raise InvalidParam("'{}' is not a looper state".format(value)) diff --git a/src/clyphx/actions/dr.py b/src/clyphx/actions/dr.py index 4d71899..4c79ee8 100644 --- a/src/clyphx/actions/dr.py +++ b/src/clyphx/actions/dr.py @@ -62,7 +62,7 @@ def scroll_selector(self, dr, args): '''Scroll Drum Rack selector up/down.''' args = args.replace('SCROLL', '').strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args) + factor = self.get_adjustment_factor(args) pos = dr.view.drum_pads_scroll_position if factor > 0: if pos < MAX_SCROLL_POS - factor: @@ -125,7 +125,7 @@ def _adjust_pad_volume(self, pads, action_arg): for pad in pads: if pad.chains: param = pad.chains[0].mixer_device.volume - self._parent.do_parameter_adjustment(param, action_arg) + self.adjust_param(param, action_arg) def _adjust_pad_pan(self, pads, action_arg): # type: (Sequence[Any], Text) -> None @@ -133,7 +133,7 @@ def _adjust_pad_pan(self, pads, action_arg): for pad in pads: if pad.chains: param = pad.chains[0].mixer_device.panning - self._parent.do_parameter_adjustment(param, action_arg) + self.adjust_param(param, action_arg) def _adjust_pad_send(self, pads, action_arg, send): # type: (Sequence[Any], Text, Text) -> None @@ -142,11 +142,12 @@ def _adjust_pad_send(self, pads, action_arg, send): for pad in pads: if pad.chains: param = pad.chains[0].mixer_device.sends[ord(send) - 65] - self._parent.do_parameter_adjustment(param, action_arg) + self.adjust_param(param, action_arg) except: pass - def _get_pads_to_operate_on(self, dr, pads): + @staticmethod + def _get_pads_to_operate_on(dr, pads): # type: (Any, Text) -> Sequence[Any] '''Get the Drum Rack pad or pads to operate on.''' pads_to_operate_on = [dr.view.selected_drum_pad] diff --git a/src/clyphx/actions/global_.py b/src/clyphx/actions/global_.py index 8a6551d..8489257 100644 --- a/src/clyphx/actions/global_.py +++ b/src/clyphx/actions/global_.py @@ -35,11 +35,12 @@ from ..consts import (AUDIO_DEVS, MIDI_DEVS, INS_DEVS, GQ_STATES, REPEAT_STATES, RQ_STATES, MIDI_STATUS) +from .scene import SceneMixin log = logging.getLogger(__name__) -class XGlobalActions(XComponent): +class XGlobalActions(XComponent, SceneMixin): '''Global actions. ''' __module__ = __name__ @@ -60,7 +61,7 @@ def __init__(self, parent): self._last_gqntz = int(self.song().clip_trigger_quantization) if self.song().midi_recording_quantization != 0: self._last_rqntz = int(self.song().midi_recording_quantization) - self._last_scene_index = list(self.song().scenes).index(self.song().view.selected_scene) + self._last_scene_index = self.sel_scene self._scenes_to_monitor = list() # type: List[Scene] self.setup_scene_listeners() @@ -76,24 +77,23 @@ def dispatch_action(self, cmd): # type: (_SingleDispatch) -> None from .consts import GLOBAL_ACTIONS - action = GLOBAL_ACTIONS[cmd.action_name] - action(self, cmd.track, cmd.xclip, cmd.ident, cmd.args) - - def on_scene_triggered(self, index): - self._last_scene_index = index - - def on_scene_list_changed(self): - self.setup_scene_listeners() + if cmd.action_name == 'SCENE': + # TODO: + # action = SCENE_ACTIONS[] + pass + else: + action = GLOBAL_ACTIONS[cmd.action_name] + action(self, cmd.track, cmd.xclip, cmd.args) - def make_instant_mapping_docs(self, track, xclip, ident, args): - # type: (None, Clip, None, Text) -> None + def make_instant_mapping_docs(self, track, xclip, args): + # type: (None, Clip, Text) -> None from ..instant_doc import InstantMappingMakeDoc InstantMappingMakeDoc() if isinstance(xclip, Clip): xclip.name = str(xclip.name).upper().replace('MAKE_DEV_DOC', 'Doc saved') - def send_midi_message(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def send_midi_message(self, track, xclip, args): + # type: (None, None, Text) -> None '''Send formatted NOTE/CC/PC message or raw MIDI message.''' message = [] if args: @@ -129,8 +129,8 @@ def send_midi_message(self, track, xclip, ident, args): except: pass - def do_variable_assignment(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def do_variable_assignment(self, track, xclip, args): + # type: (None, None, Text) -> None '''Creates numbered variables for the name given in args from the offset given in args and in the quantity given in args. ''' @@ -147,8 +147,8 @@ def do_variable_assignment(self, track, xclip, ident, args): # region TRACKS - def create_audio_track(self, track, xclip, ident, value=None): - # type: (None, None, None, Optional[Text]) -> None + def create_audio_track(self, track, xclip, value=None): + # type: (None, None, Optional[Text]) -> None '''Creates audio track at end of track list or at the specified index. ''' @@ -162,7 +162,7 @@ def create_audio_track(self, track, xclip, ident, value=None): else: self.song().create_audio_track(-1) - def create_midi_track(self, track, xclip, ident, value=None): + def create_midi_track(self, track, xclip, value=None): # type: (None, None, None, Optional[Text]) -> None '''Creates MIDI track at end of track list or at the specified index. @@ -177,20 +177,20 @@ def create_midi_track(self, track, xclip, ident, value=None): else: self.song().create_midi_track(-1) - def create_return_track(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def create_return_track(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Creates return track at end of return list.''' self.song().create_return_track() - def insert_and_configure_audio_track(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def insert_and_configure_audio_track(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Inserts an audio track next to the selected track routed from the selected track and armed. ''' self._insert_and_configure_track(is_midi=False) - def insert_and_configure_midi_track(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def insert_and_configure_midi_track(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Inserts a midi track next to the selected track routed from the selected track and armed. ''' @@ -202,7 +202,7 @@ def _insert_and_configure_track(self, is_midi=False): will only work if the selected track has the appropriate output/ input for the insertion. ''' - sel_track = self.song().view.selected_track + sel_track = self.sel_track if is_midi and not sel_track.has_midi_input: return if not is_midi and not sel_track.has_audio_output: @@ -222,8 +222,8 @@ def _insert_and_configure_track(self, is_midi=False): # endregion - def swap_device_preset(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def swap_device_preset(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Activates swapping for the selected device or swaps out the preset for the given device with the given preset or navigates forwards and back through presets. @@ -254,7 +254,7 @@ def _handle_swapping(self, device, browser_item, args): # type: (Device, Any, Text) -> None dev_items = self._create_device_items(browser_item, []) if args in ('<', '>'): - factor = self._parent.get_adjustment_factor(args) + factor = self.get_adjustment_factor(args) index = self._get_current_preset_index(device, dev_items) new_index = index + factor if new_index > len(dev_items) - 1: @@ -301,8 +301,8 @@ def _create_device_items(self, device, item_array): item_array.append(item) return item_array - def load_device(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def load_device(self, track, xclip, args): + # type: (None, None, Text) -> None '''Loads one of Live's built-in devices onto the selected Track. ''' # XXX: using a similar method for loading plugins doesn't seem to work! @@ -326,8 +326,8 @@ def load_device(self, track, xclip, ident, args): self.application().browser.load_item(dev) break - def load_m4l(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def load_m4l(self, track, xclip, args): + # type: (None, None, Text) -> None '''Loads M4L device onto the selected Track. The .amxd should be omitted by the user. ''' @@ -346,13 +346,13 @@ def load_m4l(self, track, xclip, ident, args): self.application().browser.load_item(found_dev) break - def set_session_record(self, track, xclip, ident, value=None): - # type: (None, None, None, Optional[Text]) -> None + def set_session_record(self, track, xclip, value=None): + # type: (None, None, Optional[Text]) -> None '''Toggles or turns on/off session record.''' switch(self.song(), 'session_record', value) - def trigger_session_record(self, track, xclip, ident, value=None): - # type: (None, Clip, None, Optional[Text]) -> None + def trigger_session_record(self, track, xclip, value=None): + # type: (None, Clip, Optional[Text]) -> None '''Triggers session record in all armed tracks for the specified fixed length. ''' @@ -383,13 +383,13 @@ def _track_has_empty_slot(self, track, start): return True return False - def set_session_automation_record(self, track, xclip, ident, value=None): - # type: (None, None, None, Optional[Text]) -> None + def set_session_automation_record(self, track, xclip, value=None): + # type: (None, None, Optional[Text]) -> None '''Toggles or turns on/off session automation record.''' switch(self.song(), 'session_automation_record', value) - def retrigger_recording_clips(self, track, xclip, ident, value=None): - # type: (Track, None, None, None) -> None + def retrigger_recording_clips(self, track, xclip, value=None): + # type: (Track, None, None) -> None '''Retriggers all clips that are currently recording.''' for track in self.song().tracks: if track.playing_slot_index >= 0: @@ -397,92 +397,92 @@ def retrigger_recording_clips(self, track, xclip, ident, value=None): if slot.has_clip and slot.clip.is_recording: slot.fire() - def set_back_to_arrange(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def set_back_to_arrange(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Triggers back to arrange button.''' self.song().back_to_arranger = 0 - def set_overdub(self, track, xclip, ident, value=None): - # type: (None, None, None, Optional[Text]) -> None + def set_overdub(self, track, xclip, value=None): + # type: (None, None, Optional[Text]) -> None '''Toggles or turns on/off overdub.''' switch(self.song(), 'overdub', value) - def set_metronome(self, track, xclip, ident, value=None): - # type: (None, None, None, Optional[Text]) -> None + def set_metronome(self, track, xclip, value=None): + # type: (None, None, Optional[Text]) -> None '''Toggles or turns on/off metronome.''' switch(self.song(), 'metronome', value) - def set_record(self, track, xclip, ident, value=None): - # type: (None, None, None, Optional[Text]) -> None + def set_record(self, track, xclip, value=None): + # type: (None, None, Optional[Text]) -> None '''Toggles or turns on/off record.''' switch(self.song(), 'record_mode', value) - def set_punch_in(self, track, xclip, ident, value=None): - # type: (None, None, None, Optional[Text]) -> None + def set_punch_in(self, track, xclip, value=None): + # type: (None, None, Optional[Text]) -> None '''Toggles or turns on/off punch in.''' switch(self.song(), 'punch_in', value) - def set_punch_out(self, track, xclip, ident, value=None): - # type: (None, None, None, Optional[Text]) -> None + def set_punch_out(self, track, xclip, value=None): + # type: (None, None, Optional[Text]) -> None '''Toggles or turns on/off punch out.''' switch(self.song(), 'punch_out', value) - def restart_transport(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def restart_transport(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Restarts transport to 0.0''' self.song().current_song_time = 0 - def set_stop_transport(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def set_stop_transport(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Toggles transport.''' self.song().is_playing = not self.song().is_playing - def set_continue_playback(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def set_continue_playback(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Continue playback from stop point.''' self.song().continue_playing() - def set_stop_all(self, track, xclip, ident, value=None): - # type: (None, None, None, Optional[Text]) -> None + def set_stop_all(self, track, xclip, value=None): + # type: (None, None, Optional[Text]) -> None '''Stop all clips w/no quantization option for Live 9.''' self.song().stop_all_clips((value or '').strip() != 'NQ') - def set_tap_tempo(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def set_tap_tempo(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Tap tempo.''' self.song().tap_tempo() - def set_undo(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def set_undo(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Triggers Live's undo.''' if self.song().can_undo: self.song().undo() - def set_redo(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def set_redo(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Triggers Live's redo.''' if self.song().can_redo: self.song().redo() # region NAVIGATION - def move_up(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def move_up(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Scroll up.''' self._move_nav(0) - def move_down(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def move_down(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Scroll down.''' self._move_nav(1) - def move_left(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def move_left(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Scroll left.''' self._move_nav(2) - def move_right(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def move_right(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Scroll right.''' self._move_nav(3) @@ -492,19 +492,19 @@ def _move_nav(self, direction): Application.View.NavDirection(direction), '', False ) - def move_to_first_device(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def move_to_first_device(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Move to the first device on the track and scroll the view.''' self.focus_devices() - self.song().view.selected_track.view.select_instrument() + self.sel_track.view.select_instrument() - def move_to_last_device(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def move_to_last_device(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Move to the last device on the track and scroll the view.''' self.focus_devices() - if self.song().view.selected_track.devices: + if self.sel_track.devices: self.song().view.select_device( - self.song().view.selected_track.devices[len(self.song().view.selected_track.devices) - 1] + self.sel_track.devices[len(self.sel_track.devices) - 1] ) self.application().view.scroll_view( Application.View.NavDirection(3), 'Detail/DeviceChain', False @@ -513,16 +513,16 @@ def move_to_last_device(self, track, xclip, ident, value=None): Application.View.NavDirection(2), 'Detail/DeviceChain', False ) - def move_to_prev_device(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def move_to_prev_device(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Move to the previous device on the track.''' self.focus_devices() self.application().view.scroll_view( Application.View.NavDirection(2), 'Detail/DeviceChain', False ) - def move_to_next_device(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def move_to_next_device(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Move to the next device on the track.''' self.focus_devices() self.application().view.scroll_view( @@ -534,28 +534,28 @@ def focus_devices(self): self.application().view.show_view('Detail') self.application().view.show_view('Detail/DeviceChain') - def show_clip_view(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def show_clip_view(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Show clip view.''' self.application().view.show_view('Detail') self.application().view.show_view('Detail/Clip') - def show_track_view(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def show_track_view(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Show track view.''' self.application().view.show_view('Detail') self.application().view.show_view('Detail/DeviceChain') - def show_detail_view(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def show_detail_view(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Toggle between showing/hiding detail view.''' if self.application().view.is_view_visible('Detail'): self.application().view.hide_view('Detail') else: self.application().view.show_view('Detail') - def toggle_browser(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def toggle_browser(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Hide/show browser and move focus to or from browser.''' if self.application().view.is_view_visible('Browser'): self.application().view.hide_view('Browser') @@ -564,8 +564,8 @@ def toggle_browser(self, track, xclip, ident, value=None): self.application().view.show_view('Browser') self.application().view.focus_view('Browser') - def toggle_detail_view(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def toggle_detail_view(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Toggle between clip and track view.''' self.application().view.show_view('Detail') if self.application().view.is_view_visible('Detail/Clip'): @@ -573,16 +573,16 @@ def toggle_detail_view(self, track, xclip, ident, value=None): else: self.application().view.show_view('Detail/Clip') - def toggle_main_view(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def toggle_main_view(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Toggle between session and arrange view.''' if self.application().view.is_view_visible('Session'): self.application().view.show_view('Arranger') else: self.application().view.show_view('Session') - def focus_browser(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def focus_browser(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Move the focus to the browser, show browser first if necessary. ''' @@ -590,8 +590,8 @@ def focus_browser(self, track, xclip, ident, value=None): self.application().view.show_view('Browser') self.application().view.focus_view('Browser') - def focus_detail(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def focus_detail(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Move the focus to the detail view, show detail first if necessary. ''' @@ -599,13 +599,13 @@ def focus_detail(self, track, xclip, ident, value=None): self.application().view.show_view('Detail') self.application().view.focus_view('Detail') - def focus_main(self, track, xclip, ident, value=None): - # type: (None, None, None, None) -> None + def focus_main(self, track, xclip, value=None): + # type: (None, None, None) -> None '''Move the focus to the main focu.''' self.application().view.focus_view('') - def adjust_horizontal_zoom(self, track, xclip, ident, value): - # type: (None, None, None, Text) -> None + def adjust_horizontal_zoom(self, track, xclip, value): + # type: (None, None, Text) -> None '''Horizontally zoom in in Arrange the number of times specified in value. This can accept ALL, but doesn't have any bearing. ''' @@ -623,8 +623,8 @@ def adjust_horizontal_zoom(self, track, xclip, ident, value): Application.View.NavDirection(direct), '', zoom_all ) - def adjust_vertical_zoom(self, track, xclip, ident, value): - # type: (None, None, None, Text) -> None + def adjust_vertical_zoom(self, track, xclip, value): + # type: (None, None, Text) -> None '''Vertically zoom in on the selected track in Arrange the number of times specified in value. This can accept ALL for zooming all tracks. @@ -641,14 +641,14 @@ def adjust_vertical_zoom(self, track, xclip, ident, value): # endregion - def adjust_tempo(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def adjust_tempo(self, track, xclip, args): + # type: (None, None, Text) -> None '''Adjust/set tempo or apply smooth synced ramp.''' self._tempo_ramp_active = False self._tempo_ramp_settings = [] args = args.strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args, True) + factor = self.get_adjustment_factor(args, True) self.song().tempo = max(20, min(999, (self.song().tempo + factor))) elif args.startswith('*'): try: @@ -699,12 +699,12 @@ def apply_tempo_ramp(self, arg=None): else: self.song().tempo += self._tempo_ramp_settings[1] - def adjust_groove(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def adjust_groove(self, track, xclip, args): + # type: (None, None, Text) -> None '''Adjust/set global groove.''' args = args.strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args, True) + factor = self.get_adjustment_factor(args, True) self.song().groove_amount = max(0.0, min(1.3125, self.song().groove_amount + factor * float(1.3125 / 131.0))) else: try: @@ -712,8 +712,8 @@ def adjust_groove(self, track, xclip, ident, args): except: pass - def set_note_repeat(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def set_note_repeat(self, track, xclip, args): + # type: (None, None, Text) -> None '''Set/toggle note repeat.''' args = args.strip() if args == 'OFF': @@ -727,12 +727,12 @@ def set_note_repeat(self, track, xclip, ident, args): self._repeat_enabled = not self._repeat_enabled self._parent._c_instance.note_repeat.enabled = self._repeat_enabled - def adjust_swing(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def adjust_swing(self, track, xclip, args): + # type: (None, None, Text) -> None '''Adjust swing amount for use with note repeat.''' args = args.strip() if args.startswith(('<', '>')): - factor = self._parent.get_adjustment_factor(args, True) + factor = self.get_adjustment_factor(args, True) self.song().swing_amount = max(0.0, min(1.0, (self.song().swing_amount + factor * 0.01))) else: try: @@ -740,14 +740,14 @@ def adjust_swing(self, track, xclip, ident, args): except: pass - def adjust_global_quantize(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def adjust_global_quantize(self, track, xclip, args): + # type: (None, None, Text) -> None '''Adjust/set/toggle global quantization.''' args = args.strip() if args in GQ_STATES: self.song().clip_trigger_quantization = GQ_STATES[args] elif args in ('<', '>'): - factor = self._parent.get_adjustment_factor(args) + factor = self.get_adjustment_factor(args) new_gq = self.song().clip_trigger_quantization + factor if 0 <= new_gq < 14: self.song().clip_trigger_quantization = new_gq @@ -757,14 +757,14 @@ def adjust_global_quantize(self, track, xclip, ident, args): else: self.song().clip_trigger_quantization = self._last_gqntz - def adjust_record_quantize(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def adjust_record_quantize(self, track, xclip, args): + # type: (None, None, Text) -> None '''Adjust/set/toggle record quantization.''' args = args.strip() if args in RQ_STATES: self.song().midi_recording_quantization = RQ_STATES[args] elif args in ('<', '>'): - factor = self._parent.get_adjustment_factor(args) + factor = self.get_adjustment_factor(args) new_rq = self.song().midi_recording_quantization + factor if 0 <= new_rq < 9: self.song().midi_recording_quantization = new_rq @@ -774,8 +774,8 @@ def adjust_record_quantize(self, track, xclip, ident, args): else: self.song().midi_recording_quantization = self._last_rqntz - def adjust_time_signature(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def adjust_time_signature(self, track, xclip, args): + # type: (None, None, Text) -> None '''Adjust global time signature.''' if '/' in args: try: @@ -785,37 +785,37 @@ def adjust_time_signature(self, track, xclip, ident, args): except: pass - def set_jump_all(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def set_jump_all(self, track, xclip, args): + # type: (None, None, Text) -> None '''Jump arrange position forward/backward.''' try: self.song().jump_by(float(args)) except: pass - def set_unarm_all(self, track, xclip, ident, args): - # type: (None, None, None, None) -> None + def set_unarm_all(self, track, xclip, args): + # type: (None, None, None) -> None '''Unarm all armable track.''' for t in self.song().tracks: if t.can_be_armed and t.arm: t.arm = 0 - def set_unmute_all(self, track, xclip, ident, args): - # type: (None, None, None, None) -> None + def set_unmute_all(self, track, xclip, args): + # type: (None, None, None) -> None '''Unmute all track.''' for t in chain(self.song().tracks, self.song().return_tracks): if t.mute: t.mute = 0 - def set_unsolo_all(self, track, xclip, ident, args): - # type: (None, None, None, None) -> None + def set_unsolo_all(self, track, xclip, args): + # type: (None, None, None) -> None '''Unsolo all track.''' for t in chain(self.song().tracks, self.song().return_tracks): if t.solo: t.solo = 0 - def set_fold_all(self, track, xclip, ident, value): - # type: (None, None, None, None) -> None + def set_fold_all(self, track, xclip, value): + # type: (None, None, None) -> None '''Toggle or turn/on fold for all track.''' state_to_set = None for t in self.song().tracks: @@ -824,20 +824,20 @@ def set_fold_all(self, track, xclip, ident, value): state_to_set = not t.fold_state switch(t, 'fold_state', value, state_to_set) - def set_locator(self, track, xclip, ident, args): - # type: (None, None, None, None) -> None + def set_locator(self, track, xclip, args): + # type: (None, None, None) -> None '''Set/delete a locator at the current playback position.''' self.song().set_or_delete_cue() - def do_locator_loop_action(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def do_locator_loop_action(self, track, xclip, args): + # type: (None, None, Text) -> None '''Same as do_locator_action with name argument, but also sets arrangement loop start to pos of locator. ''' - self.do_locator_action(track, xclip, ident, args, True) + self.do_locator_action(track, xclip, args, True) - def do_locator_action(self, track, xclip, ident, args, move_loop_too=False): - # type: (None, None, None, Text, bool) -> None + def do_locator_action(self, track, xclip, args, move_loop_too=False): + # type: (None, None, Text, bool) -> None '''Jump between locators or to a particular locator. Can also move loop start to pos of locator if specified. ''' @@ -857,8 +857,8 @@ def do_locator_action(self, track, xclip, ident, args, move_loop_too=False): except: pass - def do_loop_action(self, track, xclip, ident, args): - # type: (None, None, None, Text) -> None + def do_loop_action(self, track, xclip, args): + # type: (None, None, Text) -> None '''Handle arrange loop action.''' args = args.strip() if not args or args.upper() in KEYWORDS: @@ -900,7 +900,7 @@ def move_loop_by_factor(self, args): if args == '<': factor = -(factor) elif len(args) > 1: - factor = self._parent.get_adjustment_factor(args, True) + factor = self.get_adjustment_factor(args, True) new_start = self.song().loop_start + factor if new_start < 0.0: new_start = 0.0 @@ -915,146 +915,3 @@ def set_new_loop_position(self, new_start, new_length): if 0 <= new_start and 0 <= new_length <= self.song().song_length: self.song().loop_start = new_start self.song().loop_length = new_length - -# region SCENES - - def create_scene(self, track, xclip, ident, value=None): - # type: (None, Clip, None, Optional[Text]) -> None - '''Creates scene at end of scene list or at the specified index. - ''' - current_name = None - if isinstance(xclip, Clip): - current_name = xclip.name - xclip.name = '' - if value and value.strip(): - try: - index = int(value) - 1 - if 0 <= index < len(self.song().scenes): - self.song().create_scene(index) - except: - pass - else: - self.song().create_scene(-1) - if current_name: - self._parent.schedule_message( - 4, partial(self.refresh_xclip_name, (xclip, current_name)) - ) - - def duplicate_scene(self, track, xclip, ident, args): - # type: (None, Clip, None, Text) -> None - '''Duplicates the given scene.''' - current_name = None - if isinstance(xclip, Clip) and args: - current_name = xclip.name - xclip.name = '' - self.song().duplicate_scene(self.get_scene_to_operate_on(xclip, args.strip())) - if current_name: - self._parent.schedule_message( - 4, partial(self.refresh_xclip_name, (xclip, current_name)) - ) - - def refresh_xclip_name(self, clip_info): - # type: (Tuple[Clip, str]) -> None - '''This is used for both dupe and create scene to prevent the - action from getting triggered over and over again. - ''' - if clip_info[0]: - clip_info[0].name = clip_info[1] - - def delete_scene(self, track, xclip, ident, args): - # type: (None, Clip, None, Text) -> None - '''Deletes the given scene as long as it's not the last scene in - the set. - ''' - if len(self.song().scenes) > 1: - self.song().delete_scene(self.get_scene_to_operate_on(xclip, args.strip())) - - def set_scene(self, track, xclip, ident, args): - # type: (None, Clip, None, Text) -> None - '''Sets scene to play (doesn't launch xclip).''' - args = args.strip() - scene = self.get_scene_to_operate_on(xclip, args) - if args: - # don't allow randomization unless more than 1 scene - if 'RND' in args and len(self.song().scenes) > 1: - num_scenes = len(self.song().scenes) - rnd_range = [0, num_scenes] - if '-' in args: - rnd_range_data = args.replace('RND', '').split('-') - if len(rnd_range_data) == 2: - try: - new_min = int(rnd_range_data[0]) - 1 - except: - new_min = 0 - try: - new_max = int(rnd_range_data[1]) - except: - new_max = num_scenes - if 0 < new_min and new_max < num_scenes + 1 and new_min < new_max - 1: - rnd_range = [new_min, new_max] - scene = get_random_int(0, rnd_range[1] - rnd_range[0]) + rnd_range[0] - if scene == self._last_scene_index: - while scene == self._last_scene_index: - scene = get_random_int(0, rnd_range[1] - rnd_range[0]) + rnd_range[0] - # don't allow adjustment unless more than 1 scene - elif args.startswith(('<', '>')) and len(self.song().scenes) > 1: - factor = self._parent.get_adjustment_factor(args) - if factor < len(self.song().scenes): - scene = self._last_scene_index + factor - if scene >= len(self.song().scenes): - scene -= len(self.song().scenes) - elif scene < 0 and abs(scene) >= len(self.song().scenes): - scene = -(abs(scene) - len(self.song().scenes)) - self._last_scene_index = scene - for t in self.song().tracks: - if t.is_foldable or (t.clip_slots[scene].has_clip and t.clip_slots[scene].clip == xclip): - pass - else: - t.clip_slots[scene].fire() - - def get_scene_to_operate_on(self, xclip, args): - # type: (Clip, Text) -> int - scene = list(self.song().scenes).index(self.song().view.selected_scene) - if isinstance(xclip, Clip): - scene = xclip.canonical_parent.canonical_parent.playing_slot_index - if '"' in args: - scene_name = args[args.index('"')+1:] - if '"' in scene_name: - scene_name = scene_name[0:scene_name.index('"')] - for i in range(len(self.song().scenes)): - if scene_name == self.song().scenes[i].name.upper(): - scene = i - break - elif args == 'SEL': - scene = list(self.song().scenes).index(self.song().view.selected_scene) - elif args: - try: - if 0 <= int(args) < len(self.song().scenes) + 1: - scene = int(args) - 1 - except: - pass - return scene - - def setup_scene_listeners(self): - '''Setup listeners for all scenes in set and check that last - index is in current scene range. - ''' - self.remove_scene_listeners() - scenes = self.song().scenes - if not 0 < self._last_scene_index < len(scenes): - self._last_scene_index = list(self.song().scenes).index(self.song().view.selected_scene) - for i, scene in enumerate(scenes): - self._scenes_to_monitor.append(scene) - listener = lambda index=i: self.on_scene_triggered(index) - if not scene.is_triggered_has_listener(listener): - scene.add_is_triggered_listener(listener) - - def remove_scene_listeners(self): - for i, scene in enumerate(self._scenes_to_monitor): - if scene: - listener = lambda index=i: self.on_scene_triggered(index) - if scene.is_triggered_has_listener(listener): - scene.remove_is_triggered_listener(listener) - self._scenes_to_monitor = [] - -# endregion diff --git a/src/clyphx/actions/push.py b/src/clyphx/actions/push.py index 1a5d500..eb00993 100644 --- a/src/clyphx/actions/push.py +++ b/src/clyphx/actions/push.py @@ -14,10 +14,14 @@ # You should have received a copy of the GNU Lesser General Public License # along with ClyphX. If not, see . -from __future__ import absolute_import, unicode_literals, with_statement +from __future__ import absolute_import, unicode_literals from builtins import super, dict, range from typing import TYPE_CHECKING +from pushbase.instrument_component import InstrumentComponent +from pushbase.note_editor_component import NoteEditorComponent +from Push2.scales_component import ScalesComponent + from ..core.xcomponent import ControlSurfaceComponent from ..core.live import Clip from ..consts import switch, NOTE_NAMES @@ -25,9 +29,6 @@ if TYPE_CHECKING: from typing import Any, Sequence, Text, List, Dict, Optional - - - UNWRITABLE_INDEXES = (17, 35, 53) MATRIX_MODES = dict( @@ -88,12 +89,11 @@ def set_script(self, push_script, is_push2=False): self._is_push2 = is_push2 if self._script and self._script._components: for c in self._script._components: - comp_name = c.__class__.__name__ - if comp_name == 'InstrumentComponent': + if isinstance(c, InstrumentComponent): self._ins_component = c - elif comp_name.endswith('NoteEditorComponent'): + elif isinstance(c, NoteEditorComponent): self._note_editor = c - elif comp_name == 'ScalesComponent': + elif isinstance(c, ScalesComponent): self._scales_component = c if not self._is_push2: s_mode = self._script._scales_enabler._mode_map['enabled'].mode @@ -132,7 +132,7 @@ def dispatch_action(self, track, xclip, ident, action, args): self._handle_scale_action(args.replace('SCL', '').strip(), xclip, ident) elif args.startswith('SEQ') and self._note_editor: self._handle_sequence_action(args.replace('SEQ', '').strip()) - elif args == 'DRINS' and self.song().view.selected_track.has_midi_input: + elif args == 'DRINS' and self.sel_track.has_midi_input: with self._script.component_guard(): with self._script._push_injector: self._script._note_modes.selected_mode = 'instrument' @@ -225,7 +225,7 @@ def _handle_root_note(self, arg_array): self._ins_component._note_layout.root_note = NOTE_NAMES.index(arg_array[1]) except KeyError: if arg_array[1] in ('<', '>'): - new_root = (self._parent.get_adjustment_factor(arg_array[1]) + new_root = (self.get_adjustment_factor(arg_array[1]) + self._ins_component._note_layout.root_note) if 0 <= new_root < 12: self._ins_component._note_layout.root_note = new_root @@ -240,7 +240,7 @@ def _handle_octave(self, arg_array): def _handle_scale_type(self, arg_array, args): # type: (Sequence[Text], Text) -> None if arg_array[1] in ('<', '>'): - factor = self._parent.get_adjustment_factor(arg_array[1]) + factor = self.get_adjustment_factor(arg_array[1]) if self._is_push2: idx = self._scales_component.selected_scale_index + factor self._scales_component._set_selected_scale_index(idx) @@ -271,15 +271,16 @@ def _capture_scale_settings(self, xclip, ident): # type: (Clip, Text) -> None '''Captures scale settings and writes them to X-Clip's name.''' if isinstance(xclip, Clip): - root = str(self._ins_component._note_layout.root_note) + layout = self._ins_component._note_layout + root = str(layout.root_note) if self._is_push2: scl_type = self._scales_component.selected_scale_index else: scl_type = str(self._scales_component._scale_list.scrollable_list .selected_item_index) octave = '0' - fixed = str(self._ins_component._note_layout.is_fixed) - inkey = str(self._ins_component._note_layout.is_in_key) + fixed = str(layout.is_fixed) + inkey = str(layout.is_in_key) orient = '0' xclip.name = '{} Push SCL {} {} {} {} {} {}'.format( ident, root, scl_type, octave, fixed, inkey, orient @@ -288,15 +289,16 @@ def _capture_scale_settings(self, xclip, ident): def _recall_scale_settings(self, arg_array): # type: (Sequence[Text]) -> None '''Recalls scale settings from X-Trigger name.''' + layout = self._ins_component._note_layout try: - self._ins_component._note_layout.root_note = int(arg_array[0]) + layout.root_note = int(arg_array[0]) if self._is_push2: self._scales_component._set_selected_scale_index(int(arg_array[1])) else: self._scales_component._scale_list.scrollable_list.selected_item_index =\ int(arg_array[1]) - self._ins_component._note_layout.is_fixed = arg_array[3] == 'TRUE' - self._ins_component._note_layout.is_in_key = arg_array[4] == 'TRUE' + layout.is_fixed = arg_array[3] == 'TRUE' + layout.is_in_key = arg_array[4] == 'TRUE' except: pass diff --git a/src/clyphx/actions/scene.py b/src/clyphx/actions/scene.py new file mode 100644 index 0000000..142bc97 --- /dev/null +++ b/src/clyphx/actions/scene.py @@ -0,0 +1,162 @@ +from __future__ import absolute_import, unicode_literals +from builtins import object +from typing import TYPE_CHECKING +import logging + +if TYPE_CHECKING: + from typing import Optional, Text, Tuple + from ..core.live import Clip + +log = logging.getLogger(__name__) + + +class SceneMixin(object): + + def create_scene(self, track, xclip, value=None): + # type: (None, Clip, Optional[Text]) -> None + '''Creates scene at end of scene list or at the specified index. + ''' + if isinstance(xclip, Clip): + current_name = xclip.name + xclip.name = '' + else: + current_name = None + if value and value.strip(): + try: + index = int(value) - 1 + if 0 <= index < len(self.song().scenes): + self.song().create_scene(index) + except Exception as e: + msg = "Failed to evaluate create_scene value '%s': {%r}" + log.error(msg, value, e) + else: + self.song().create_scene(-1) + if current_name: + self._parent.schedule_message( + 4, partial(self.refresh_xclip_name, (xclip, current_name)) + ) + + def duplicate_scene(self, track, xclip, args): + # type: (None, Clip, Text) -> None + '''Duplicates the given scene.''' + if isinstance(xclip, Clip) and args: + current_name = xclip.name + xclip.name = '' + else: + current_name = None + self.song().duplicate_scene(self.get_scene_to_operate_on(xclip, args.strip())) + if current_name: + self._parent.schedule_message( + 4, partial(self.refresh_xclip_name, (xclip, current_name)) + ) + + def refresh_xclip_name(self, clip_info): + # type: (Tuple[Clip, str]) -> None + '''This is used for both dupe and create scene to prevent the + action from getting triggered over and over again. + ''' + if clip_info[0]: + clip_info[0].name = clip_info[1] + + def delete_scene(self, track, xclip, args): + # type: (None, Clip, Text) -> None + '''Deletes the given scene as long as it's not the last scene in + the set. + ''' + if len(self.song().scenes) > 1: + self.song().delete_scene(self.get_scene_to_operate_on(xclip, args.strip())) + + def set_scene(self, track, xclip, args): + # type: (None, Clip, Text) -> None + '''Sets scene to play (doesn't launch xclip). + ''' + args = args.strip() + scene = self.get_scene_to_operate_on(xclip, args) + if args: + # don't allow randomization unless more than 1 scene + if 'RND' in args and len(self.song().scenes) > 1: + num_scenes = len(self.song().scenes) + rnd_range = [0, num_scenes] + if '-' in args: + rnd_range_data = args.replace('RND', '').split('-') + if len(rnd_range_data) == 2: + try: + new_min = int(rnd_range_data[0]) - 1 + except: + new_min = 0 + try: + new_max = int(rnd_range_data[1]) + except: + new_max = num_scenes + if 0 < new_min and new_max < num_scenes + 1 and new_min < new_max - 1: + rnd_range = [new_min, new_max] + scene = get_random_int(0, rnd_range[1] - rnd_range[0]) + rnd_range[0] + if scene == self._last_scene_index: + while scene == self._last_scene_index: + scene = get_random_int(0, rnd_range[1] - rnd_range[0]) + rnd_range[0] + # don't allow adjustment unless more than 1 scene + elif args.startswith(('<', '>')) and len(self.song().scenes) > 1: + factor = self.get_adjustment_factor(args) + if factor < len(self.song().scenes): + scene = self._last_scene_index + factor + if scene >= len(self.song().scenes): + scene -= len(self.song().scenes) + elif scene < 0 and abs(scene) >= len(self.song().scenes): + scene = -(abs(scene) - len(self.song().scenes)) + self._last_scene_index = scene + for t in self.song().tracks: + if not (t.is_foldable or (t.clip_slots[scene].has_clip and t.clip_slots[scene].clip == xclip)): + t.clip_slots[scene].fire() + + def get_scene_to_operate_on(self, xclip, args): + # type: (Clip, Text) -> int + if args == 'SEL' or not isinstance(xclip, Clip): + scene = self.sel_scene + else: + scene = xclip.canonical_parent.canonical_parent.playing_slot_index + + if '"' in args: + scene_name = args[args.index('"')+1:] + if '"' in scene_name: + scene_name = scene_name[0:scene_name.index('"')] + for i in range(len(self.song().scenes)): + if scene_name == self.song().scenes[i].name.upper(): + scene = i + break + elif args and args != 'SEL': + try: + if 0 <= int(args) < len(self.song().scenes) + 1: + scene = int(args) - 1 + except: + pass + return scene + +# region LISTENERS + def on_scene_triggered(self, index): + self._last_scene_index = index + + def on_scene_list_changed(self): + self.setup_scene_listeners() + + def setup_scene_listeners(self): + '''Setup listeners for all scenes in set and check that last + index is in current scene range. + ''' + self.remove_scene_listeners() + scenes = self.song().scenes + if not 0 < self._last_scene_index < len(scenes): + self._last_scene_index = self.sel_scene + for i, scene in enumerate(scenes): + self._scenes_to_monitor.append(scene) + listener = lambda index=i: self.on_scene_triggered(index) + if not scene.is_triggered_has_listener(listener): + scene.add_is_triggered_listener(listener) + + def remove_scene_listeners(self): + for i, scene in enumerate(self._scenes_to_monitor): + if scene: + listener = lambda index=i: self.on_scene_triggered(index) + if scene.is_triggered_has_listener(listener): + scene.remove_is_triggered_listener(listener) + self._scenes_to_monitor = [] +# endregion diff --git a/src/clyphx/actions/snap.py b/src/clyphx/actions/snap.py index dd59995..32a80a2 100644 --- a/src/clyphx/actions/snap.py +++ b/src/clyphx/actions/snap.py @@ -226,20 +226,22 @@ def recall_track_snapshot(self, name, xclip, disable_smooth=False): self._parameters_to_smooth = dict() self._rack_parameters_to_smooth = dict() is_synced = False if disable_smooth else self._init_smoothing(xclip) + for track, param_data in snap_data.items(): + pos = param_data[PLAY_SETTINGS_POS] if track in self.current_tracks: track = self.current_tracks[track] self._recall_mix_settings(track, param_data) - if (param_data[PLAY_SETTINGS_POS] is not None and - not track.is_foldable and - track is not self.song().master_track): - if param_data[PLAY_SETTINGS_POS] < 0: + if (pos is not None and not track.is_foldable + and track is not self.song().master_track): + if pos < 0: track.stop_all_clips() - elif (track.clip_slots[param_data[PLAY_SETTINGS_POS]].has_clip and - track.clip_slots[param_data[PLAY_SETTINGS_POS]].clip != xclip): - track.clip_slots[param_data[PLAY_SETTINGS_POS]].fire() - if param_data[DEVICE_SETTINGS_POS]: + elif (track.clip_slots[pos].has_clip + and track.clip_slots[pos].clip != xclip): + track.clip_slots[pos].fire() + if pos: self._recall_device_settings(track, param_data) + if self._is_control_track and self._parameters_to_smooth: if (not self._control_rack or (self._control_rack and @@ -252,47 +254,44 @@ def recall_track_snapshot(self, name, xclip, disable_smooth=False): def _recall_mix_settings(self, track, param_data): # type: (Track, Mapping[int, Any]) -> None '''Recalls mixer related settings.''' - if param_data[MIX_STD_SETTINGS_POS]: - pan_value = param_data[MIX_STD_SETTINGS_POS][MIX_PAN_POS] - if (track.mixer_device.volume.is_enabled and - param_data[MIX_STD_SETTINGS_POS][MIX_VOL_POS] != -1): + std = param_data[MIX_STD_SETTINGS_POS] + if std: + pan_value = std[MIX_PAN_POS] + if (track.mixer_device.volume.is_enabled and std[MIX_VOL_POS] != -1): self._get_parameter_data_to_smooth( - track.mixer_device.volume, - param_data[MIX_STD_SETTINGS_POS][MIX_VOL_POS] - ) + track.mixer_device.volume, std[MIX_VOL_POS]) + if track.mixer_device.panning.is_enabled and not isinstance(pan_value, int): self._get_parameter_data_to_smooth( - track.mixer_device.panning, - param_data[MIX_STD_SETTINGS_POS][MIX_PAN_POS] - ) + track.mixer_device.panning, std[MIX_PAN_POS]) + if track is not self.song().master_track: - for i in range(len(param_data[MIX_STD_SETTINGS_POS]) - MIX_SEND_START_POS): - if (i <= len(track.mixer_device.sends) - 1 and - track.mixer_device.sends[i].is_enabled): + sends = track.mixer_device.sends + for i in range(len(std) - MIX_SEND_START_POS): + if (i <= len(sends) - 1 and sends[i].is_enabled): self._get_parameter_data_to_smooth( - track.mixer_device.sends[i], - param_data[MIX_STD_SETTINGS_POS][MIX_SEND_START_POS+i] - ) + sends[i], std[MIX_SEND_START_POS + i]) + if param_data[1] and track is not self.song().master_track: - track.mute = param_data[MIX_EXT_SETTINGS_POS][MIX_MUTE_POS] - track.solo = param_data[MIX_EXT_SETTINGS_POS][MIX_SOLO_POS] - track.mixer_device.crossfade_assign = param_data[MIX_EXT_SETTINGS_POS][MIX_CF_POS] + ext = param_data[MIX_EXT_SETTINGS_POS] + track.mute = ext[MIX_MUTE_POS] + track.solo = ext[MIX_SOLO_POS] + track.mixer_device.crossfade_assign = ext[MIX_CF_POS] def _recall_device_settings(self, track, param_data): # type: (Track, Mapping[int, Any]) -> None '''Recalls device related settings.''' + settings = param_data[DEVICE_SETTINGS_POS] for device in track.devices: - if device.name in param_data[DEVICE_SETTINGS_POS]: - self._recall_device_snap(device, param_data[DEVICE_SETTINGS_POS][device.name]['params']) - if (self._include_nested_devices and - self._parent._can_have_nested_devices and - device.can_have_chains and - 'chains' in param_data[DEVICE_SETTINGS_POS][device.name]): + if device.name in settings: + self._recall_device_snap(device, settings[device.name]['params']) + if (self._include_nested_devices + and self._parent._can_have_nested_devices + and device.can_have_chains + and 'chains' in settings[device.name]): self._recall_nested_device_snap( - device, - param_data[DEVICE_SETTINGS_POS][device.name]['chains'], - ) - del param_data[DEVICE_SETTINGS_POS][device.name] + device, settings[device.name]['chains']) + del settings[device.name] def _recall_device_snap(self, device, stored_params): # type: (Device, Any) -> None @@ -497,7 +496,8 @@ def _get_parameter_data_to_smooth(self, parameter, new_value): else: parameter.value = new_value - def _get_snap_device_range(self, args, track): + @staticmethod + def _get_snap_device_range(args, track): # type: (Text, Track) -> Tuple[int, int] '''Returns range of devices to snapshot.''' dev_args = (args.replace('MIX', '').replace('PLAY', '') diff --git a/src/clyphx/actions/track.py b/src/clyphx/actions/track.py index e271fa3..162a7e0 100644 --- a/src/clyphx/actions/track.py +++ b/src/clyphx/actions/track.py @@ -81,7 +81,7 @@ def create_clip(self, track, xclip, args): in the selected slot. ''' if track.has_midi_input: - slot = list(self.song().scenes).index(self.song().view.selected_scene) + slot = self.sel_scene bar = (4.0 / self.song().signature_denominator) * self.song().signature_numerator length = bar if args: @@ -173,7 +173,7 @@ def set_xfade(self, track, xclip, args): def set_selection(self, track, xclip, args): # type: (Track, None, Text) -> None '''Sets track/slot selection.''' - self.song().view.selected_track = track + self.sel_track = track if track in self.song().tracks: if args: try: @@ -262,14 +262,13 @@ def _get_slot_index_to_play(self, track, xclip, args, allow_empty_slots=False): slot_to_play = -1 play_slot = track.playing_slot_index - select_slot = list(self.song().scenes).index(self.song().view.selected_scene) if not args: if isinstance(xclip, Clip): slot_to_play = xclip.canonical_parent.canonical_parent.playing_slot_index else: - slot_to_play = play_slot if play_slot >= 0 else select_slot + slot_to_play = play_slot if play_slot >= 0 else self.sel_scene elif args == 'SEL': - slot_to_play = select_slot + slot_to_play = self.sel_scene # TODO: repeated, check refactoring # don't allow randomization unless more than 1 scene elif 'RND' in args and len(self.song().scenes) > 1: @@ -296,7 +295,7 @@ def _get_slot_index_to_play(self, track, xclip, args, allow_empty_slots=False): elif args.startswith(('<', '>')) and len(self.song().scenes) > 1: if track.is_foldable: return -1 - factor = self._parent.get_adjustment_factor(args) + factor = self.get_adjustment_factor(args) if factor < len(self.song().scenes): # only launch slots that contain clips if abs(factor) == 1: @@ -338,25 +337,25 @@ def adjust_preview_volume(self, track, xclip, args): # type: (Track, None, Text) -> None '''Adjust/set master preview volume.''' if track == self.song().master_track: - self._parent.do_parameter_adjustment( + self.adjust_param( self.song().master_track.mixer_device.cue_volume, args.strip()) def adjust_crossfader(self, track, xclip, args): # type: (Track, None, Text) -> None '''Adjust/set master crossfader.''' if track == self.song().master_track: - self._parent.do_parameter_adjustment( + self.adjust_param( self.song().master_track.mixer_device.crossfader, args.strip()) def adjust_volume(self, track, xclip, args): # type: (Track, None, Text) -> None '''Adjust/set track volume.''' - self._parent.do_parameter_adjustment(track.mixer_device.volume, args.strip()) + self.adjust_param(track.mixer_device.volume, args.strip()) def adjust_pan(self, track, xclip, args): # type: (Track, None, Text) -> None '''Adjust/set track pan.''' - self._parent.do_parameter_adjustment(track.mixer_device.panning, args.strip()) + self.adjust_param(track.mixer_device.panning, args.strip()) def adjust_sends(self, track, xclip, args): # type: (Track, None, Text) -> None @@ -365,7 +364,7 @@ def adjust_sends(self, track, xclip, args): if len(args) > 1: param = self.get_send_parameter(track, largs[0].strip()) if param: - self._parent.do_parameter_adjustment(param, largs[1].strip()) + self.adjust_param(param, largs[1].strip()) def get_send_parameter(self, track, send_string): # type: (Track, Text) -> Optional[Any] @@ -429,7 +428,7 @@ def handle_track_routing(self, args, routings, current_routing): new_routing = routings[current_routing] args = args.strip() if args in ('<', '>'): - factor = self._parent.get_adjustment_factor(args) + factor = self.get_adjustment_factor(args) if 0 <= (current_routing + factor) < len(routings): new_routing = routings[current_routing + factor] else: diff --git a/src/clyphx/clyphx.py b/src/clyphx/clyphx.py index 4728351..8fb1fd3 100644 --- a/src/clyphx/clyphx.py +++ b/src/clyphx/clyphx.py @@ -13,8 +13,8 @@ # # You should have received a copy of the GNU Lesser General Public License # along with ClyphX. If not, see . -from __future__ import with_statement, absolute_import, unicode_literals -from builtins import super, dict, range, map +from __future__ import absolute_import, unicode_literals +from builtins import super, dict, range, map, filter, list from typing import TYPE_CHECKING from functools import partial from itertools import chain @@ -25,7 +25,8 @@ from .core.legacy import _DispatchCommand, _SingleDispatch from .core.utils import repr_tracklist, set_user_profile from .core.live import Live, Track, DeviceIO, Clip, get_random_int -# from .core.parse import SpecParser +from .core.parse import IdSpecParser, ObjParser +from .core.xcomponent import XComponent from .consts import LIVE_VERSION, SCRIPT_INFO from .extra_prefs import ExtraPrefs from .user_config import get_user_settings @@ -78,7 +79,8 @@ def __init__(self, c_instance): self._PushApcCombiner = None self._process_xclips_if_track_muted = True self._user_settings = get_user_settings() - # self.parse = SpecParser() + self.parse_id = IdSpecParser() + self.parse_obj = ObjParser() with self.component_guard(): self.macrobat = Macrobat(self) self._extra_prefs = ExtraPrefs(self, self._user_settings.prefs) @@ -105,13 +107,12 @@ def __init__(self, c_instance): log.info(msg, SCRIPT_INFO, '.'.join(map(str, LIVE_VERSION))) self.show_message(SCRIPT_INFO) - from .dev_doc import get_device_params - from .core.utils import get_user_clyphx_path - path = get_user_clyphx_path('live_instant_mapping.md') - log.info('Updating Live Instant Mapping info') - with open(path, 'w') as f: - f.write(get_device_params(format='md', tables=True)) # type: ignore - + # from .dev_doc import get_device_params + # from .core.utils import get_user_clyphx_path + # path = get_user_clyphx_path('live_instant_mapping.md') + # log.info('Updating Live Instant Mapping info') + # with open(path, 'w') as f: + # f.write(get_device_params(format='md', tables=True)) # type: ignore def disconnect(self): for attr in ( @@ -165,11 +166,11 @@ def _handle_dispatch_command(self, cmd): Main dispatch for calling appropriate class of actions, passes all necessary arguments to class method. ''' - name = cmd.action_name - if not cmd.tracks: - return # how? + return + name = cmd.action_name + log.info('CMD %s', cmd) if name.startswith('SNAP'): self.snap_actions.dispatch_actions(cmd) elif name.startswith('DEV'): @@ -180,6 +181,7 @@ def _handle_dispatch_command(self, cmd): self.device_actions.dispatch_looper_actions(cmd) elif name in TRACK_ACTIONS: self.track_actions.dispatch_actions(cmd) + # elif name in GLOBAL_ACTIONS or name.startswith('SCENE'): elif name in GLOBAL_ACTIONS: self.global_actions.dispatch_action(cmd.to_single()) elif name.startswith('DR'): @@ -188,12 +190,13 @@ def _handle_dispatch_command(self, cmd): self.cs_actions.dispatch_action(cmd.to_single()) elif name in self.user_actions._action_dict: self.dispatch_user_actions(cmd) - elif name == 'PSEQ' and cmd.args== 'RESET': + elif name == 'PSEQ' and cmd.args == 'RESET': for v in self._play_seq_clips.values(): v[1] = -1 elif name == 'DEBUG': if isinstance(cmd.xclip, Clip): - cmd.xclip.name = str(cmd.xclip.name).upper().replace('DEBUG', 'Debugging Activated') + name = str(cmd.xclip.name).upper() + cmd.xclip.name = name.replace('DEBUG', 'Debugging Activated') self.start_debugging() else: log.error('Not found dispatcher for %r', cmd) @@ -231,80 +234,140 @@ def handle_m4l_trigger(self, name): ActionList('[] {}'.format(name))) def handle_action_list_trigger(self, track, xtrigger): + # type: (Track, XTrigger) -> None + log.info('ClyphX.handle_action_list_trigger' + '(track=%r, xtrigger=%r)', track, xtrigger) + + if xtrigger == None: # TODO: use case? + return + + try: + self.run_statement(track, xtrigger) + except Exception as e: + # snap actions parsing not implemented + # use legacy method as a fallback + log.error("Failed to run statement '%s': %r", xtrigger.name, e) + self._handle_action_list_trigger(track, xtrigger) + + def run_statement(self, track, xtrigger): + # type: (Track, XTrigger) -> Any + stmt = xtrigger.name.strip().upper() + spec = self.parse_id(stmt) + + log.info('run_statement: %s', spec) + if spec.override: + # control reassignment, so pass to control component + self.control_component.assign_new_actions(stmt) + + elif isinstance(xtrigger, Clip): + # X-Clips can have on and off action lists + # TODO: if xtrigger.is_triggered? + log.info('Clip is triggered: %s, is_playing: %s', xtrigger.is_triggered, xtrigger.is_playing) + if not xtrigger.is_playing: + if not spec.off: + return + elif spec.off != '*': + # TODO: select actual list on dispatching + spec.on = spec.off + + # lseq: accessible only to X-Clips + if spec.seq == 'LSEQ': + actions = self._format_action_list(track, spec.on) + self._loop_seq_clips[xtrigger.name] = [spec.id, actions] + return self.handle_loop_seq_action_list(xtrigger, 0) + + actions = self._format_action_list(track, spec.on) + + # pseq: accessible to any X-Trigger (except for Startup Actions) + if spec.seq == 'PSEQ': + return self.handle_play_seq_action_list(actions, xtrigger, spec.id) + + for action in actions: + # TODO: split in singledispatch per track? + command = _DispatchCommand(action['track'], + xtrigger, + spec.id, + action['action'], + action['args']) + self.handle_dispatch_command(command) + + def _handle_action_list_trigger(self, track, xtrigger): # type: (Track, XTrigger) -> None '''Directly dispatches snapshot recall, X-Control overrides and Seq X-Clips. Otherwise, separates ident from action names, splits up lists of action names and calls action dispatch. ''' - log.debug('ClyphX.handle_action_list_trigger' - '(track=%r, xtrigger=%r)', track, xtrigger) - if xtrigger == None: - # TODO: use case? - return - name = xtrigger.name.strip().upper() - if name and name[0] == '[' and ']' in name: + if not (name and name[0] == '[' and ']' in name): + return - # snap action, so pass directly to snap component - if ' || (' in name and isinstance(xtrigger, Clip) and xtrigger.is_playing: - # self.snap_actions.recall_track_snapshot(name, xtrigger) - self.snap_actions.recall_track_snapshot(None, xtrigger) + # snap action, so pass directly to snap component + # TODO: xtrigger.is_triggered? + if ' || (' in name and isinstance(xtrigger, Clip) and xtrigger.is_playing: + # self.snap_actions.recall_track_snapshot(name, xtrigger) + self.snap_actions.recall_track_snapshot(None, xtrigger) - # control reassignment, so pass directly to control component - elif '[[' in name and ']]' in name: - self.control_component.assign_new_actions(name) + # control reassignment, so pass directly to control component + elif '[[' in name and ']]' in name: + self.control_component.assign_new_actions(name) - # standard trigger - else: - ident = name[name.index('['):name.index(']')+1].strip() - raw_action_list = name.replace(ident, '', 1).strip() + # standard trigger + else: + ident = name[name.index('['):name.index(']')+1].strip() + raw_action_list = name.replace(ident, '', 1).strip() + if not raw_action_list: + return + is_play_seq = False + is_loop_seq = False + + # X-Clips can have on and off action lists + if isinstance(xtrigger, Clip): + raw_action_list = get_xclip_action_list(xtrigger, raw_action_list) if not raw_action_list: return - is_play_seq = False - is_loop_seq = False - - # X-Clips can have on and off action lists - if isinstance(xtrigger, Clip): - raw_action_list = get_xclip_action_list(xtrigger, raw_action_list) - if not raw_action_list: - return - - # check if the trigger is a PSEQ (accessible to any type of X-Trigger) - if raw_action_list[0] == '(' and '(PSEQ)' in raw_action_list: - is_play_seq = True - raw_action_list = raw_action_list.replace('(PSEQ)', '').strip() - - # check if the trigger is a LSEQ (accessible only to X-Clips) - elif (isinstance(xtrigger, Clip) and - raw_action_list[0] == '(' and - '(LSEQ)' in raw_action_list): - is_loop_seq = True - raw_action_list = raw_action_list.replace('(LSEQ)', '').strip() - - # build formatted action list - formatted_action_list = [] # List[Dict[Text, Any]] - for action_ in raw_action_list.split(';'): - action_data = self.format_action_name(track, action_.strip()) - if action_data: - formatted_action_list.append(action_data) - - # if seq, pass to appropriate function, else call action - # dispatch for each action in the formatted action list - if formatted_action_list: - if is_play_seq: - self.handle_play_seq_action_list(formatted_action_list, xtrigger, ident) - elif is_loop_seq: - self._loop_seq_clips[xtrigger.name] = [ident, formatted_action_list] - self.handle_loop_seq_action_list(xtrigger, 0) - else: - # TODO: split in singledispatch per track? - for action in formatted_action_list: - command = _DispatchCommand(action['track'], - xtrigger, - ident, - action['action'], - action['args']) - self.handle_dispatch_command(command) + + # check if the trigger is a PSEQ (accessible to any type of X-Trigger) + if raw_action_list[0] == '(' and '(PSEQ)' in raw_action_list: + is_play_seq = True + raw_action_list = raw_action_list.replace('(PSEQ)', '').strip() + + # check if the trigger is a LSEQ (accessible only to X-Clips) + elif (isinstance(xtrigger, Clip) and + raw_action_list[0] == '(' and + '(LSEQ)' in raw_action_list): + is_loop_seq = True + raw_action_list = raw_action_list.replace('(LSEQ)', '').strip() + + # build formatted action list + formatted_action_list = self._format_action_list(track, raw_action_list) + # formatted_action_list = [] # List[Dict[Text, Any]] + # for action_ in raw_action_list.split(';'): + # action_data = self.format_action_name(track, action_.strip()) + # if action_data: + # formatted_action_list.append(action_data) + + # if seq, pass to appropriate function, else call action + # dispatch for each action in the formatted action list + if formatted_action_list: + if is_play_seq: + self.handle_play_seq_action_list(formatted_action_list, xtrigger, ident) + elif is_loop_seq: + self._loop_seq_clips[xtrigger.name] = [ident, formatted_action_list] + self.handle_loop_seq_action_list(xtrigger, 0) + else: + # TODO: split in singledispatch per track? + for action in formatted_action_list: + command = _DispatchCommand(action['track'], + xtrigger, + ident, + action['action'], + action['args']) + self.handle_dispatch_command(command) + + def _format_action_list(self, track, alist): + # (Text) -> List[Text] + alist = [self.format_action_name(track, a) for a in alist] + return list(filter(None, alist)) def format_action_name(self, origin_track, origin_name): # type: (Any, Text) -> Optional[Dict[Text, Any]] @@ -336,16 +399,15 @@ def handle_loop_seq_action_list(self, xclip, count): '''Handles sequenced action lists, triggered by xclip looping. ''' if xclip.name in self._loop_seq_clips: - if count >= len(self._loop_seq_clips[xclip.name][1]): - count -= ( - (count // len(self._loop_seq_clips[xclip.name][1])) - * len(self._loop_seq_clips[xclip.name][1]) - ) - action = self._loop_seq_clips[xclip.name][1][count] + entry = self._loop_seq_clips[xclip.name] + + if count >= len(clip[1]): + count -= (count // len(entry[1])) * len(entry[1]) + action = entry[1][count] # TODO: _SingleDispatch? command = _DispatchCommand(action['track'], xclip, - self._loop_seq_clips[xclip.name][0], + entry[0], action['action'], action['args']) self.handle_dispatch_command(command) @@ -365,76 +427,12 @@ def handle_play_seq_action_list(self, action_list, xclip, ident): action = self._play_seq_clips[xclip.name][2][self._play_seq_clips[xclip.name][1]] # TODO: _SingleDispatch? command = _DispatchCommand(action['track'], - xclip, - self._loop_seq_clips[xclip.name][0], - action['action'], - action['args']) + xclip, + self._loop_seq_clips[xclip.name][0], + action['action'], + action['args']) self.handle_dispatch_command(command) - def do_parameter_adjustment(self, param, value): - # type: (DeviceParameter, Text) -> None - '''Adjust (, reset, random, set val) continuous params, also - handles quantized param adjustment (should just use +1/-1 for - those). - ''' - if not param.is_enabled: - return - step = (param.max - param.min) / 127 - new_value = param.value - if value.startswith(('<', '>')): - factor = self.get_adjustment_factor(value) - new_value += factor if param.is_quantized else (step * factor) - elif value == 'RESET' and not param.is_quantized: - new_value = param.default_value - elif 'RND' in value and not param.is_quantized: - rnd_min = 0 - rnd_max = 128 - if value != 'RND' and '-' in value: - rnd_range_data = value.replace('RND', '').split('-') - if len(rnd_range_data) == 2: - try: - new_min = int(rnd_range_data[0]) - except: - new_min = 0 - try: - new_max = int(rnd_range_data[1]) + 1 - except: - new_max = 128 - if 0 <= new_min and new_max <= 128 and new_min < new_max: - rnd_min = new_min - rnd_max = new_max - rnd_value = (get_random_int(0, 128) * (rnd_max - rnd_min) / 127) + rnd_min - new_value = (rnd_value * step) + param.min - - else: - try: - if 0 <= int(value) < 128: - try: - new_value = (int(value) * step) + param.min - except: - new_value = param.value - except: - pass - if param.min <= new_value <= param.max: - param.value = new_value - log.debug('do_parameter_adjustment called on %s, set value to %s', - param.name, new_value) - - def get_adjustment_factor(self, string, as_float=False): - # type: (Text, bool) -> Union[int, float] - '''Get factor for use with < > actions.''' - factor = 1 - - if len(string) > 1: - try: - factor = (float if as_float else int)(string[1:]) - except: - factor = 1 - - if string.startswith('<'): - factor = -(factor) - return factor - def get_track_to_operate_on(self, origin_name): # type: (Text) -> Tuple[List[Any], Text] '''Gets track or tracks to operate on.''' @@ -462,7 +460,8 @@ def get_track_to_operate_on(self, origin_name): track_index = -1 if spec.startswith(('<', '>')): try: - track_index = (self.get_adjustment_factor(spec) + # FIXME: + track_index = (XComponent.get_adjustment_factor(spec) + sel_track_index) except: pass @@ -489,7 +488,8 @@ def get_track_to_operate_on(self, origin_name): repr_tracklist(result_tracks), result_name) return (result_tracks, result_name) - def get_track_index_by_name(self, name, tracks): + @staticmethod + def get_track_index_by_name(name, tracks): # type: (Text, Sequence[Any]) -> Text '''Gets the index(es) associated with the track name(s) specified in name. @@ -620,7 +620,7 @@ def perform_startup_actions(self, action_list): ActionList(action_list)) def setup_tracks(self): - '''Setup component tracks on ini and track list changes. Also + '''Setup component tracks on init and track list changes. Also call Macrobat's get rack. ''' for t in self.song().tracks: diff --git a/src/clyphx/consts.py b/src/clyphx/consts.py index 2b5541d..463108d 100644 --- a/src/clyphx/consts.py +++ b/src/clyphx/consts.py @@ -18,6 +18,8 @@ from typing import TYPE_CHECKING import logging +from _Generic.Devices import DEVICE_DICT, DEVICE_BOB_DICT + from . import __version__ from .core.live import (GridQuantization, MixerDevice, @@ -38,15 +40,11 @@ app.get_minor_version(), app.get_bugfix_version()) -if LIVE_VERSION < (9, 5, 0): - raise RuntimeError('Live releases earlier than 9.5 are not supported') - +if LIVE_VERSION < (9, 6, 0): + raise RuntimeError('Live releases earlier than 9.6 are not supported') SCRIPT_NAME = 'ClyphX' - -SCRIPT_VERSION = __version__ - -SCRIPT_INFO = '{} v{}'.format(SCRIPT_NAME, '.'.join(map(str, SCRIPT_VERSION))) +SCRIPT_INFO = '{} v{}'.format(SCRIPT_NAME, '.'.join(map(str, __version__))) KEYWORDS = dict(ON=1, OFF=0) # type: Mapping[Optional[Text], bool] @@ -93,6 +91,7 @@ def switch(obj, attr, value, fallback=unset): # ('COUNT', WarpMode.count), )) # type: Mapping[Text, int] +# region STATES XFADE_STATES = dict( A = MixerDevice.crossfade_assignments.A, OFF = MixerDevice.crossfade_assignments.NONE, @@ -112,7 +111,6 @@ def switch(obj, attr, value, fallback=unset): OVER = 3.0, ) # type: Mapping[Text, float] -# region STATES # TODO: check gq, rq,r_qnrz, and clip_grid uses # XXX: bars vs beats GQ_STATES = dict(( @@ -293,4 +291,10 @@ def switch(obj, attr, value, fallback=unset): MidiVelocity = 'Velocity', Vinyl = 'Vinyl Distortion', ) + +# BoB is B0, device banks have the index of its label 'B1' = 1, ... +DEVICE_BANKS = dict() +for device, banks in DEVICE_DICT.items(): + DEVICE_BANKS[device] = DEVICE_BOB_DICT[device] + banks + # endregion diff --git a/src/clyphx/core/exceptions.py b/src/clyphx/core/exceptions.py index 6b8a8b6..d06d299 100644 --- a/src/clyphx/core/exceptions.py +++ b/src/clyphx/core/exceptions.py @@ -10,3 +10,9 @@ class InvalidSpec(ClyphXception, ValueError): class InvalidParam(ClyphXception, ValueError): pass + +class InvalidAction(ClyphXception, TypeError): + pass + +class ActionUnavailable(ClyphXception, TypeError): + pass diff --git a/src/clyphx/core/live.py b/src/clyphx/core/live.py index a99a5f1..dbf15ed 100644 --- a/src/clyphx/core/live.py +++ b/src/clyphx/core/live.py @@ -11,6 +11,7 @@ Chain, Clip, CompressorDevice, + Conversions, Device, DeviceIO, DeviceParameter, diff --git a/src/clyphx/core/models.py b/src/clyphx/core/models.py index ac2358b..dae528d 100644 --- a/src/clyphx/core/models.py +++ b/src/clyphx/core/models.py @@ -5,14 +5,17 @@ # SPDX-License-Identifier: LGPL-2.1-or-later from __future__ import absolute_import, unicode_literals -from typing import TYPE_CHECKING, NamedTuple, List, Text +from typing import TYPE_CHECKING, NamedTuple, List, Text, Optional, Any from builtins import object from ..consts import MIDI_STATUS from .utils import repr_slots +from .parse_notes import parse_pitch, pitch_note +from .exceptions import InvalidParam if TYPE_CHECKING: - from typing import Optional, Dict, Union + from typing import Dict, Union + from numbers import Integral Action = NamedTuple('Action', [('tracks', List[Text]), @@ -27,6 +30,91 @@ ('off', List[Action])]) +IdSpec = NamedTuple('Spec', [('id', Text), + ('seq', Text), + ('on', Text), + ('off', Optional[Text]), + ('override', bool)]) + + +class Pitch(int): + '''Coverts either a numeric value or a note + octave string into a + constrained integer [0,127] with `note` and `octave` properties. + + `note` and `octave`, if not provided, are lazy evaluated. + ''' + def __new__(cls, value): + # type: (Union[Text, Integral]) -> 'Pitch' + try: # numeric value + self = super().__new__(cls, value) + name, octave = None, None + except ValueError: # note + octave + name, octave, val = parse_pitch(value) + self = super().__new__(cls, val) + self._name = name + self._octave = octave + if 0 <= self < 128: + return self + raise ValueError(int(self)) + + @property + def name(self): + # type: () -> str + if self._name is None: + self._name, self._octave = pitch_note(self) + return self._name + + @property + def octave(self): + # type: () -> int + if self._octave is None: + self._name, self._octave = pitch_note(self) + return self._octave + + def __add__(self, other): + return self.__class__(int(self) + other) + + def __radd__(self, other): + return self.__add__(other) + + def __sub__(self, other): + return self.__class__(int(self) - other) + + def __rsub__(self, other): + return self.__class__(other - int(self)) + + def __mul__(self, other): + '''Up `other` octaves.''' + return self.__add__(other * 12) + + def __rmul__(self, other): + return self.__add__(other * 12) + + def __div__(self, other): + '''Down `other` octaves.''' + return self.__sub__(other * 12) + + def __rdiv__(self, other): + return self.__sub__(other * 12) + + def __bool__(self): + return True + + def __repr__(self): + return 'Pitch({}, {})'.format(int(self), self) + + def __str__(self): + return '{}{}'.format(self.name, self.octave) + + +# TODO +Note = NamedTuple('Note', [('pitch', int), # TODO: Pitch + ('start', float), + ('length', Any), + ('vel', Any), + ('mute', bool)]) + + class UserControl(object): ''' X-Controls defined in user settings. @@ -46,12 +134,12 @@ class UserControl(object): def __init__(self, name, type, channel, value, on_actions, off_actions=None): # type: (Text, Text, Union[Text, int], Union[Text, int], Text, Optional[Text]) -> None - self.name = name - self.type = type.upper() - self.channel = int(channel) - 1 - self.value = int(value) + self.name = name + self.type = type.upper() + self.channel = int(channel) - 1 + self.value = Pitch(value) if self.type == 'NOTE' else int(value) # TODO: parse action lists - self.on_actions = '[{}] {}'.format(name, on_actions.strip()) + self.on_actions = '[{}] {}'.format(name, on_actions.strip()) if off_actions == '*': self.off_actions = self.on_actions # type: Optional[Text] elif off_actions: @@ -73,7 +161,7 @@ def status_byte(self): try: return MIDI_STATUS[self.type] except KeyError: - raise ValueError("Message type must be 'NOTE' or 'CC'") + raise InvalidParam("Message type must be 'NOTE' or 'CC'") @classmethod def parse(cls, name, data): @@ -87,9 +175,9 @@ def _validate(self): # TODO: check valid identifier if not (0 <= self.channel <= 15): - raise ValueError('MIDI channel must be an integer between 1 and 16') + raise InvalidParam('MIDI channel must be an integer between 1 and 16') - if not (0 <= self.value <= 127): - raise ValueError('NOTE or CC must be an integer between 0 and 127') + if not (0 <= self.value < 128): + raise InvalidParam('NOTE or CC must be an integer between 0 and 127') __repr__ = repr_slots diff --git a/src/clyphx/core/parse.py b/src/clyphx/core/parse.py index 6579774..5e48e31 100644 --- a/src/clyphx/core/parse.py +++ b/src/clyphx/core/parse.py @@ -5,123 +5,97 @@ # SPDX-License-Identifier: LGPL-2.1-or-later from __future__ import absolute_import, unicode_literals +from builtins import map, object, dict from typing import TYPE_CHECKING -from builtins import map, object import re -from .models import Action, Spec, UserControl +from .models import IdSpec from .exceptions import ParsingError if TYPE_CHECKING: - from typing import Any, Optional, Text, Iterator, Iterable - - -# region TERMINAL SYMBOLS -OBJECT_SYMBOLS = { - 'SEL', - 'LAST', - '<', - '>', - 'ALL', - 'MST', - 'RACKS', - 'DETAIL', - 'SELF', - # return tracks ( index = ord(track) - 65 ) - 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', -} - -TRACKCNT = r'\b(?P\d{1,2})\b' -TRACKRET = r'\b(?P[A-La-l])\b' -TRACKNAM = r'\b\"(?P[\w\s\]+?)\"\b' -TRACKSYM = r'\b(?P[A-Za-z]\w+?)\b' -TRACK = r'({}|{}|{}|{})'.format(TRACKCNT, TRACKRET, TRACKNAM, TRACKSYM) -TRACKRG = r'(?P{}\-{})'.format(TRACK.replace('P<', 'P\w*?)', + OVERRIDE = r'(?:\[(?P\w*?)\])', # X-Control override + SEQ = r'\(R?(?P[PL]SEQ)\)', +) + +NONTERMINALS = dict( + LISTS = r'(?P[\w<>"]\S.*?)', + TOKENS = r'(?=[^|;]\s*?)(\S.*?)\s*?(?=$|;)', +) + +SYMBOLS = TERMINALS.copy() +SYMBOLS.update(NONTERMINALS) + + +class IdSpecParser(object): + #: spec structure spec = re.compile(r''' - \[(?P\w*?)\]\s*? - (\((?PR?[PL]SEQ)\))?\s*? - (?P\S.*?)\s*?$ - ''', re.I | re.X) + ^\[({IDENT}|{OVERRIDE})?\]\s*? + ({SEQ}\s*)? + {LISTS}\s*?$ + '''.format(**SYMBOLS), re.I | re.X) #: action lists lists = re.compile(r''' (?P[^:]*?) - (?:\s*?[:]\s*?(?P\S[^:]*?))?\s*?$ + (?:\s*?[:]\s*? + (?P\S[^:]*?))?\s*?$ ''', re.X) + tokens = re.compile(NONTERMINALS['TOKENS']) + + def _parse(self, string): + # type: (Text) -> IdSpec + + # extract identifier and seq + spec = self.spec.search(string).groupdict() + + # split lists + lists = self.lists.match(spec.pop('lists')).groupdict().items() + spec.update({k: self.tokens.findall(v) if v else v for k, v in lists}) + + if spec['override']: + spec.update(id=spec['override'], override=True) + else: + spec['override'] = False - #: list actions - actions = re.compile(r'^\s+|\s*;\s*|\s+$') - - #: action structure - action = re.compile( - r'^((?P[\w<>"\-,\s]*?)\s?/)?' - r'(?P[A-Z0-9]+?)' - r'(\((?P[A-Z0-9"\-,<>.]+?)\))?' - r'(\s+?(?P(?! ).*))?$' - , re.I) - - splittracks = re.compile(r'\s?[\\A,\\Z]\s?').split - - #: tracks definition - tracks = re.compile(r''' - \"(?P[A-Z0-9\-<>\s]+?)?\"| - (?:^|[\s,]?(?P[A-Z0-9<>]+?)| - (?P[A-Z0-9\-<>]+?))(?:[\s,]|$) # ranges - ''', re.I | re.X) - - def parse_tracks(self, tracks): - # type: (Text) -> Optional[Iterable[Text]] - if not tracks: - return None - - # TODO: split before parsing - # tracks = self.splittracks(tracks) - - return [{k:v for k, v in m.groupdict().items() if v} - for m in self.tracks.finditer(tracks)] - - # TODO: lexing and syntactic validation - def parse_args(self, args): - # type: (Text) -> Optional[Iterable[Text]] - if not args: - return None - return args.split() - - def parse_action_list(self, actions): - # type: (Text) -> Iterator[Action] + return IdSpec(**spec) + + def __call__(self, string): + # type: (Text) -> IdSpec try: - for a in self.actions.split(actions): - action = self.action.match(a).groupdict() - action.update(tracks=self.parse_tracks(action['tracks']), # type: ignore - args=self.parse_args(action['args'])) - yield Action(**action) - except (AttributeError, TypeError): - raise ValueError("'{}' is not a valid action list".format(actions)) - - def __call__(self, text): - # type: (Text) -> Spec - - # split the label into 'id', 'seq' (if any) and action 'lists' - spec = self.spec.search(text).groupdict() - - # split previous 'lists' into 'on' and 'off' action lists - on, off = self.lists.match(spec.pop('lists')).groups() + return self._parse(string) + except Exception: + raise ParsingError(string) + + +TERMINALS = dict( + POS = r'(?P\d+?)', + SEL = r'(?PSEL)', + NAME = r'\"(?P[A-Z0-9\-<>\s]+?)?\"', +) + + +class ObjParser(object): + + clip = re.compile(r'CLIP({POS}|{SEL}|{NAME})?'.format(**TERMINALS), re.I) + + def _parse(self, kind, string): + match = getattr(self, kind).match(string) + return {k: v for k, v in match.groupdict().items() if v} + + def __call__(self, kind, string): + # type: (Text, Text) -> Dict[Text, Text] try: - off = list(self.parse_action_list(off)) - except ValueError as e: - if not off or off == '*': - off = off or None - else: - # raise ParsingError(e) - raise Exception(e) - - spec.update(on=list(self.parse_action_list(on)), off=off) # type: ignore - return Spec(**spec) + return self._parse(kind, string) + except Exception: + raise ParsingError(string) + + +__all__ = ['IdSpecParser', 'ObjParser'] + +del (TERMINALS, NONTERMINALS, SYMBOLS) diff --git a/src/clyphx/core/parse_notes.py b/src/clyphx/core/parse_notes.py new file mode 100644 index 0000000..60ebbe3 --- /dev/null +++ b/src/clyphx/core/parse_notes.py @@ -0,0 +1,55 @@ +# coding: utf-8 +# +# Copyright (c) 2020-2021 Nuno André Novo +# Some rights reserved. See COPYING, COPYING.LESSER +# SPDX-License-Identifier: LGPL-2.1-or-later + +from __future__ import absolute_import, unicode_literals +from typing import TYPE_CHECKING +from builtins import dict +import re + +if TYPE_CHECKING: + from typing import Text + + +NOTE = dict([ + ('C', 0), + ('Db', 1), ('C#', 1), + ('D', 2), + ('Eb', 3), ('D#', 3), + ('E', 4), + ('F', 5), + ('Gb', 6), ('F#', 6), + ('G', 7), + ('Ab', 8), ('G#', 8), + ('A', 9), + ('Bb', 10), ('A#', 10), + ('B', 11), +]) +NOTE_INDEX = dict((v, k) for k, v in NOTE.items()) # returns sharp notes + +OCTAVE = ('-2', '-1', '0', '1', '2', '3', '4', '5', '6', '7', '8') + +PITCH = re.compile('({})({})'.format('|'.join(NOTE), '|'.join(OCTAVE)), re.I) + + +def parse_pitch(string): + # type: (Text) -> (Text, Text) + try: + name, octave = PITCH.match(string).groups() + name = name.upper() + value = NOTE[name] + OCTAVE.index(octave) * 12 + return name, int(octave), value + except AttributeError: + raise ValueError(string) + + +def pitch_note(pitch): + # type: (int) -> (Text, int) + return NOTE_INDEX[pitch % 12], int(OCTAVE[pitch // 12]) + + +__all__ = ['parse_pitch', 'pitch_note'] + +del (NOTE, OCTAVE, NOTE_INDEX, PITCH) diff --git a/src/clyphx/core/utils.py b/src/clyphx/core/utils.py index 6a54b02..5637451 100644 --- a/src/clyphx/core/utils.py +++ b/src/clyphx/core/utils.py @@ -44,8 +44,8 @@ def get_base_path(*items): def get_user_clyphx_path(*items): - base = '~/.{}'.format(SCRIPT_NAME.lower()) - base = os.path.expanduser(base) + name = '.' + SCRIPT_NAME.lower() + base = os.path.join(os.path.expanduser('~'), name) dest = os.path.join(base, *items) return os.path.realpath(dest) @@ -55,3 +55,7 @@ def set_user_profile(): if not os.path.exists(path): os.mkdir(path) os.mkdir(os.path.join(path, 'log')) + + +def logger(): + pass diff --git a/src/clyphx/core/xcomponent.py b/src/clyphx/core/xcomponent.py index 7f9cccd..d92e977 100644 --- a/src/clyphx/core/xcomponent.py +++ b/src/clyphx/core/xcomponent.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from typing import Any + from .live import Track log = logging.getLogger(__name__) @@ -28,7 +29,8 @@ def disconnect(self): '''Called by the control surface on disconnect (app closed, script closed). ''' - log.debug('Disconnecting %s', getattr(self, 'name', 'a %s' % self.__class__.name)) + log.debug('Disconnecting %s', + getattr(self, 'name', 'a {}'.format(self.__class__.name))) self._parent = None super().disconnect() @@ -47,3 +49,84 @@ def update(self): def _update(self): super().update() + + @property + def sel_track(self): + # type: () -> Track + return self.song().view.selected_track + + @sel_track.setter + def sel_track(self, track): + # type: (Track) -> None + self.song().view.selected_track = track + + @property + def sel_scene(self): + # type: () -> int + return list(self.song().scenes).index(self.song().view.selected_scene) + + @staticmethod + def do_parameter_adjustment(param, value): + # type: (DeviceParameter, Text) -> None + '''Adjust (, reset, random, set val) continuous params, also + handles quantized param adjustment (should just use +1/-1 for + those). + ''' + if not param.is_enabled: + return + step = (param.max - param.min) / 127 + new_value = param.value + if value.startswith(('<', '>')): + factor = XComponent.get_adjustment_factor(value) + new_value += factor if param.is_quantized else (step * factor) + elif value == 'RESET' and not param.is_quantized: + new_value = param.default_value + elif 'RND' in value and not param.is_quantized: + rnd_min = 0 + rnd_max = 128 + if value != 'RND' and '-' in value: + rnd_range_data = value.replace('RND', '').split('-') + if len(rnd_range_data) == 2: + try: + new_min = int(rnd_range_data[0]) + except: + new_min = 0 + try: + new_max = int(rnd_range_data[1]) + 1 + except: + new_max = 128 + if 0 <= new_min and new_max <= 128 and new_min < new_max: + rnd_min = new_min + rnd_max = new_max + rnd_value = (get_random_int(0, 128) * (rnd_max - rnd_min) / 127) + rnd_min + new_value = (rnd_value * step) + param.min + + else: + try: + if 0 <= int(value) < 128: + try: + new_value = (int(value) * step) + param.min + except: + new_value = param.value + except: + pass + if param.min <= new_value <= param.max: + param.value = new_value + log.debug('do_parameter_adjustment called on %s, set value to %s', + param.name, new_value) + + @staticmethod + def get_adjustment_factor(string, as_float=False): + # type: (Text, bool) -> Union[int, float] + '''Get factor for use with < > actions.''' + factor = 1 + + if len(string) > 1: + try: + factor = (float if as_float else int)(string[1:]) + except: + factor = 1 + + if string.startswith('<'): + factor = -(factor) + return factor diff --git a/src/clyphx/dev_doc.py b/src/clyphx/dev_doc.py index fec463b..116f1a8 100644 --- a/src/clyphx/dev_doc.py +++ b/src/clyphx/dev_doc.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, unicode_literals from typing import TYPE_CHECKING +# TODO: get from .consts.DEVICE_BANKS from _Generic.Devices import DEVICE_DICT, DEVICE_BOB_DICT, BANK_NAME_DICT from .consts import LIVE_VERSION, DEV_NAME_TRANSLATION diff --git a/src/clyphx/extra_prefs.py b/src/clyphx/extra_prefs.py index 070233d..3fbecdf 100644 --- a/src/clyphx/extra_prefs.py +++ b/src/clyphx/extra_prefs.py @@ -45,7 +45,7 @@ def __init__(self, parent, config): self._clip_record_slot = None # type: Optional[Any] self._midi_clip_length = config.get('default_inserted_midi_clip_length', False) # type: bool self._midi_clip_length_slot = None # type: Optional[Any] - self._last_track = self.song().view.selected_track # type: Track + self._last_track = self.sel_track # type: Track self.on_selected_track_changed() def disconnect(self): @@ -61,7 +61,7 @@ def on_selected_track_changed(self): functions. ''' super().on_selected_track_changed() - track = self.song().view.selected_track + track = self.sel_track clip_slot = self.song().view.highlighted_clip_slot self.remove_listeners() if self._show_highlight: @@ -70,9 +70,7 @@ def on_selected_track_changed(self): (self.song().master_track,)) if self.song().view.selected_track in tracks: self._parent._set_session_highlight( - tracks.index(self.song().view.selected_track), - list(self.song().scenes).index(self.song().view.selected_scene), - 1, 1, True, + tracks.index(self.song().view.selected_track), self.sel_scene, 1, 1, True, ) else: self._parent._set_session_highlight(-1, -1, -1, -1, False) @@ -124,8 +122,7 @@ def clip_record_slot_changed(self): '''Called on slot has clip changed. Checks if clip is recording and retriggers it if so. ''' - track = self.song().view.selected_track - if self.song().clip_trigger_quantization != 0 and track.arm: + if self.song().clip_trigger_quantization != 0 and self.sel_track.arm: clip = self._clip_record_slot.clip if clip and clip.is_recording: clip.fire() diff --git a/src/clyphx/m4l_browser.py b/src/clyphx/m4l_browser.py index b443e49..d321d2a 100644 --- a/src/clyphx/m4l_browser.py +++ b/src/clyphx/m4l_browser.py @@ -70,7 +70,7 @@ def activate_hotswap(self): device. ''' # type: () -> List[Any] - device = self.song().view.selected_track.view.selected_device + device = self.sel_track.view.selected_device items = [] if device: if self.application().view.browse_mode: @@ -157,7 +157,7 @@ def _track_contains_browser(self): '''Returns whether or not the selected track contains the Device Browser, in which case hotswapping isn't possble. ''' - for device in self.song().view.selected_track.devices: + for device in self.sel_track.devices: if device and device.name == 'Device Browser': return True return False @@ -165,7 +165,7 @@ def _track_contains_browser(self): def _create_devices_for_tag(self, tag): '''Creates dict of devices for the given tag. Special handling is needed for M4L tag, which only contains folders, and Drums - tag, which ontains devices and folders. + tag, which contains devices and folders. ''' # type: (Text) -> Dict[Text, Any] device_dict = dict() diff --git a/src/clyphx/macrobat/consts.py b/src/clyphx/macrobat/consts.py index b682b8a..724f4de 100644 --- a/src/clyphx/macrobat/consts.py +++ b/src/clyphx/macrobat/consts.py @@ -29,6 +29,16 @@ ('NK CS', MacrobatChainSelectorRack), # param rack ]) +RNR_EXCLUDED = ( + 'NK RND', + 'NK RST', + 'NK CHAIN MIX', + 'NK DR', + 'NK LEARN', + 'NK RECEIVER', + 'NK TRACK', + 'NK SIDECHAIN', +) # {param: (mess_type, reset)} RNR_ON_OFF = dict([ diff --git a/src/clyphx/macrobat/macrobat.py b/src/clyphx/macrobat/macrobat.py index f417304..82679c7 100644 --- a/src/clyphx/macrobat/macrobat.py +++ b/src/clyphx/macrobat/macrobat.py @@ -73,7 +73,7 @@ def disconnect(self): super().disconnect() def update(self): - if self._track and self.song().view.selected_track == self._track: + if self._track and self.sel_track == self._track: self.setup_devices() def reallow_updates(self): diff --git a/src/clyphx/macrobat/midi_rack.py b/src/clyphx/macrobat/midi_rack.py index 69813e7..5d33aeb 100644 --- a/src/clyphx/macrobat/midi_rack.py +++ b/src/clyphx/macrobat/midi_rack.py @@ -117,11 +117,16 @@ def do_sysex(self): send_new_val = True for byte in p[1][0]: if byte == -1: - new_val = int((((p[1][2] - p[1][1]) / 127.0) * int(p[0].value)) + p[1][1]) - if int((((p[1][2] - p[1][1]) / 127.0) * p[2]) + p[1][1]) != new_val: + if int(p[0].value) != p[2]: + new_val = int((((p[1][2] - p[1][1]) / 127.0) * int(p[0].value)) + p[1][1]) new_string.append(new_val) else: send_new_val = False + # new_val = int((((p[1][2] - p[1][1]) / 127.0) * int(p[0].value)) + p[1][1]) + # if int((((p[1][2] - p[1][1]) / 127.0) * p[2]) + p[1][1]) != new_val: + # new_string.append(new_val) + # else: + # send_new_val = False else: new_string.append(byte) if send_new_val: @@ -155,7 +160,8 @@ def check_sysex_list(self, name_string): result = entry[1:4] return result - def check_for_channel(self, name): + @staticmethod + def check_for_channel(name): # type: (Text) -> int '''Check for user-specified channel in rack name. ''' @@ -171,7 +177,8 @@ def check_for_channel(self, name): log.error("Invalid MIDI channel number: '%d'", value) return 0 - def check_for_cc_num(self, name): + @staticmethod + def check_for_cc_num(name): # type: (Text) -> Optional[int] '''Check for user-specified CC# in macro name. ''' diff --git a/src/clyphx/macrobat/parameter_rack_template.py b/src/clyphx/macrobat/parameter_rack_template.py index 250ed8f..7ebb8a2 100644 --- a/src/clyphx/macrobat/parameter_rack_template.py +++ b/src/clyphx/macrobat/parameter_rack_template.py @@ -57,11 +57,13 @@ def setup_device(self, rack): self._on_off_param = [rack.parameters[0], rack.parameters[0].value] rack.parameters[0].add_value_listener(self.on_off_changed) - def scale_macro_value_to_param(self, macro, param): + @staticmethod + def scale_macro_value_to_param(macro, param): # type: (DeviceParameter, DeviceParameter) -> float return (((param.max - param.min) / 127.0) * macro.value) + param.min - def scale_param_value_to_macro(self, param): + @staticmethod + def scale_param_value_to_macro(param): # type: (DeviceParameter) -> int return int(((param.value - param.min) / (param.max - param.min)) * 127.0) diff --git a/src/clyphx/macrobat/parameter_racks.py b/src/clyphx/macrobat/parameter_racks.py index 6582631..dd2e04a 100644 --- a/src/clyphx/macrobat/parameter_racks.py +++ b/src/clyphx/macrobat/parameter_racks.py @@ -247,7 +247,8 @@ def get_sender_macros(self, dev_list): for c in d.chains: self.get_sender_macros(c.devices) - def get_ident_macros(self, rack): + @staticmethod + def get_ident_macros(rack): # type: (RackDevice) -> Sequence[Tuple[Text, DeviceParameter]] '''Get send and receiver macros.''' macros = [] @@ -433,7 +434,8 @@ def disconnect(self): setattr(self, attr, None) super().disconnect() - def scale_macro_value_to_param(self, macro, param): + @staticmethod + def scale_macro_value_to_param(macro, param): return (((param.max - param.min) / 126.0) * macro.value) + param.min def setup_device(self, rack): diff --git a/src/clyphx/macrobat/push_rack.py b/src/clyphx/macrobat/push_rack.py index 2d4ef6b..8428503 100644 --- a/src/clyphx/macrobat/push_rack.py +++ b/src/clyphx/macrobat/push_rack.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with ClyphX. If not, see . -from __future__ import with_statement, absolute_import, unicode_literals +from __future__ import absolute_import, unicode_literals from builtins import super from typing import TYPE_CHECKING @@ -120,7 +120,8 @@ def _update_rack_name(self): self._push_ins._note_layout.scale.name, ) - def scale_macro_value_to_param(self, macro, hi_value): + @staticmethod + def scale_macro_value_to_param(macro, hi_value): # type: (Any, Number) -> int '''Scale the value of the macro to the Push parameter being controlled. diff --git a/src/clyphx/macrobat/rnr_rack.py b/src/clyphx/macrobat/rnr_rack.py index ec07932..b9ea99d 100644 --- a/src/clyphx/macrobat/rnr_rack.py +++ b/src/clyphx/macrobat/rnr_rack.py @@ -135,6 +135,8 @@ def get_devices_to_operate_on(self, dev_list, devices_to_get): def get_next_device(self, rnr_rack, dev_list, store_next=False): # type: (RackDevice, List[RackDevice], bool) -> None '''Get the next non-RnR device on the track or in the chain.''' + from .consts import RNR_EXCLUDED + for d in dev_list: if d and not store_next: if d == rnr_rack: @@ -145,10 +147,7 @@ def get_next_device(self, rnr_rack, dev_list, store_next=False): isinstance(d.canonical_parent, Chain) ): name = d.name.upper() - if d and not name.startswith( - ('NK RND', 'NK RST', 'NK CHAIN MIX', 'NK DR', - 'NK LEARN', 'NK RECEIVER', 'NK TRACK', 'NK SIDECHAIN') - ): + if d and not name.startswith(RNR_EXCLUDED): self._devices_to_operate_on.append(d) if self._parent._can_have_nested_devices: if isinstance(rnr_rack.canonical_parent, Chain): diff --git a/src/clyphx/macrobat/sidechain_rack.py b/src/clyphx/macrobat/sidechain_rack.py index a3ed444..a8479bd 100644 --- a/src/clyphx/macrobat/sidechain_rack.py +++ b/src/clyphx/macrobat/sidechain_rack.py @@ -99,9 +99,8 @@ def update_macros(self, val): if self._rack: for p in self._rack.parameters: name = p.name.upper() - if name.startswith('DEVICE'): - if p.value == 0.0: - return + if name.startswith('DEVICE') and p.value == 0.0: + return elif name.startswith('[SC]') and p.is_enabled: if self._track.output_meter_level == 0.0 or ( self._track.has_audio_output and diff --git a/src/clyphx/push_apc_combiner.py b/src/clyphx/push_apc_combiner.py index 246e37c..27b0f77 100644 --- a/src/clyphx/push_apc_combiner.py +++ b/src/clyphx/push_apc_combiner.py @@ -16,6 +16,7 @@ from __future__ import absolute_import, unicode_literals from builtins import super +from typing import TYPE_CHECKING from ableton.v2.control_surface.components.session_ring import SessionRingComponent from ableton.v2.control_surface.elements import TouchEncoderElement @@ -25,7 +26,6 @@ from .core.xcomponent import XComponent, SessionComponent -from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any, Iterable, Optional, Text, Dict, List from .core.live import MidiRemoteScript @@ -76,7 +76,8 @@ def set_up_scripts(self, scripts): self._apc_session.add_offset_listener(self._on_apc_offset_changed) self._on_apc_offset_changed() - def _get_session_component(self, script): + @staticmethod + def _get_session_component(script): # type: (MidiRemoteScript) -> Optional[Any] '''Get the session component for the given script. ''' diff --git a/src/clyphx/triggers/base.py b/src/clyphx/triggers/base.py index 592ac49..0ff31b1 100644 --- a/src/clyphx/triggers/base.py +++ b/src/clyphx/triggers/base.py @@ -23,6 +23,9 @@ def __init__(self, name='none'): class XTrigger(XComponent): + # only available for X-Clips + can_loop_seq = False + def __init__(self, parent): # type: (Any) -> None super().__init__(parent) @@ -38,4 +41,7 @@ def ref_track(self): It's the song's selected track for all triggers except for X-Clips, where is the host track. ''' - return self._parent.song().selected_track + return self.sel_track + + def handle_action_list(self, track, xtrigger): + self._parent.handle_action_list_trigger(track, xtrigger) diff --git a/src/clyphx/triggers/clip.py b/src/clyphx/triggers/clip.py index bb94865..17e4ff8 100644 --- a/src/clyphx/triggers/clip.py +++ b/src/clyphx/triggers/clip.py @@ -5,7 +5,7 @@ import logging if TYPE_CHECKING: - from typing import Text + from typing import Optional, Text from ..core.live import Clip from .base import XTrigger @@ -17,7 +17,7 @@ def get_xclip_action_list(xclip, full_action_list): # type: (Clip, Text) -> Text '''Get the action list to perform. - X-Clips can have an on and off action list separated by a comma. + X-Clips can have an on and off action list separated by a colon. This will return which action list to perform based on whether the clip is playing. If the clip is not playing and there is no off action, this returns None. @@ -41,8 +41,11 @@ def get_xclip_action_list(xclip, full_action_list): class XClip(XTrigger): - + '''A control on a Session View Clip. + ''' can_have_off_list = True + can_loop_seq = True + __module__ = __name__ def __init__(self, parent, clip): @@ -54,6 +57,23 @@ def __init__(self, parent, clip): def update(self): super().update() + @property + def slot(self): + # type: () -> int + return self._clip.canonical_parent.canonical_parent.playing_slot_index + # return self.ref_track.playing_slot_index + + @property + def actions(self): + # type: () -> Optional[Text] + # TODO: split actions + if self.is_playing: + return self.spec.on + elif self.spec.off == '*': + return self.spec.on + elif self.spec.off: + return self.spec.off + @property def is_playing(self): # type: () -> bool @@ -77,3 +97,4 @@ def ref_track(self): X-Clips, where is the host track. ''' raise NotImplementedError + # return self._clip.canonical_parent.canonical_parent diff --git a/src/clyphx/triggers/control.py b/src/clyphx/triggers/control.py index b31d354..c2f4176 100644 --- a/src/clyphx/triggers/control.py +++ b/src/clyphx/triggers/control.py @@ -15,6 +15,8 @@ class XControlComponent(XTrigger): '''A control on a MIDI controller. ''' + can_have_off_list = True + __module__ = __name__ def __init__(self, parent): @@ -95,8 +97,7 @@ def receive_midi(self, bytes): ctrl_data = self._control_list[(bytes[0], bytes[1])] ctrl_data['name'].name = ctrl_data['on_action'] if ctrl_data: - self._parent.handle_action_list_trigger(self.song().view.selected_track, - ctrl_data['name']) + self.handle_action_list(self.ref_rack, ctrl_data['name']) def get_user_controls(self, settings, midi_map_handle): # type: (Dict[Text, Text], int) -> None diff --git a/src/clyphx/triggers/cue.py b/src/clyphx/triggers/cue.py index 68011d1..e51a421 100644 --- a/src/clyphx/triggers/cue.py +++ b/src/clyphx/triggers/cue.py @@ -84,8 +84,7 @@ def set_x_point_time_to_watch(self): self._x_point_time_to_watch_for = -1 def schedule_x_point_action_list(self, point): - self._parent.handle_action_list_trigger(self.song().view.selected_track, - self._x_points[point]) + self.handle_action_list(self.ref_track, self._x_points[point]) def remove_cue_point_listeners(self): for cp in self.song().cue_points: diff --git a/src/clyphx/triggers/track.py b/src/clyphx/triggers/track.py index c6104d2..4867e8f 100644 --- a/src/clyphx/triggers/track.py +++ b/src/clyphx/triggers/track.py @@ -47,15 +47,12 @@ def play_slot_index_changed(self): new_clip = self.get_xclip(self._track.playing_slot_index) prev_clip = self.get_xclip(self._last_slot_index) self._last_slot_index = self._track.playing_slot_index - if new_clip and prev_clip and new_clip == prev_clip: - self._triggered_clips.append(new_clip) - elif new_clip: - if prev_clip: - self._triggered_clips.append(prev_clip) - self._triggered_clips.append(new_clip) - elif prev_clip: + if prev_clip: self._triggered_clips.append(prev_clip) + if new_clip and new_clip != prev_clip: + self._triggered_clips.append(new_clip) self._clip = new_clip + # FIXME if (self._clip and '(LSEQ)' in self._clip.name.upper() and not self._clip.loop_jump_has_listener(self.on_loop_jump)): self._clip.add_loop_jump_listener(self.on_loop_jump) @@ -68,6 +65,7 @@ def get_xclip(self, slot_index): slot = self._track.clip_slots[slot_index] if slot.has_clip and not slot.clip.is_recording and not slot.clip.is_triggered: clip_name = slot.clip.name + # TODO: make XClip if len(clip_name) > 2 and clip_name[0] == '[' and ']' in clip_name: clip = slot.clip return clip diff --git a/src/clyphx/user_actions.py b/src/clyphx/user_actions.py index 27071d3..534bf8b 100644 --- a/src/clyphx/user_actions.py +++ b/src/clyphx/user_actions.py @@ -145,8 +145,8 @@ def example_action_one(self, track, args): the action list as a string(proceeded by an identifier). ''' log.info('example_action_one triggered with args=%s', args) - self._parent.handle_action_list_trigger(self.song().view.selected_track, - ActionList('[] METRO {}'.format(args))) + self._parent.handle_action_list_trigger( + self.sel_track,ActionList('[] METRO {}'.format(args))) def example_action_two(self, track, args): # type: (Track, Text) -> None diff --git a/src/clyphx/user_config.py b/src/clyphx/user_config.py index 9625859..6315816 100644 --- a/src/clyphx/user_config.py +++ b/src/clyphx/user_config.py @@ -4,7 +4,7 @@ # Some rights reserved. See COPYING, COPYING.LESSER # SPDX-License-Identifier: LGPL-2.1-or-later -from __future__ import absolute_import, unicode_literals, with_statement +from __future__ import absolute_import, unicode_literals from typing import TYPE_CHECKING from builtins import dict, object import logging @@ -165,7 +165,7 @@ class UserVars(object): # new format: %VARNAME% re_var = re.compile(r'%(\w+?)%') - # legacy format: $VARNAME + # legacy format: $VARNAME re_legacy_var = re.compile(r'\$(\w+?)\b') def __getitem__(self, key):