From 5569a82b22855ba5fc63cf5218e531ecfb9412f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20Andr=C3=A9?= Date: Tue, 2 Mar 2021 20:56:37 +0100 Subject: [PATCH] refactor: add pitch model and parsing --- README.md | 15 +- docs/macrobat_manual.md | 197 +++++--------- src/clyphx/actions/clip.py | 7 +- src/clyphx/actions/clip_notes.py | 443 ++++++++++++------------------- src/clyphx/actions/consts.py | 44 ++- src/clyphx/consts.py | 2 - src/clyphx/core/models.py | 23 +- src/clyphx/core/parse_notes.py | 28 +- 8 files changed, 312 insertions(+), 447 deletions(-) diff --git a/README.md b/README.md index 63749d6..d4f3b2d 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,16 @@ ClyphX ====== -**ClyphX** is a _MIDI Remote Script_ for Ableton Live 9.6+, 10, and 11 that -provides an extensive list of _Actions_ related to controlling different aspects -of Live. +**ClyphX** is a _MIDI Remote Script_ for Ableton Live 9.6 - 11 that provides an +extensive list of _Actions_ related to controlling different aspects of Live. As an example, a simple _Action_ might toggle Overdub on/off: ``` 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: +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 ``` @@ -22,12 +21,12 @@ _Action_ or a list of _Actions_. **ClyphX** also includes: -- A component called **Macrobat** that adds new functionality to Racks in Live - such as the ability to control Track mixer parameters or send MIDI messages +- A component called **Macrobat** that adds new functionality to racks in Live + such as the ability to control track mixer parameters or send MIDI messages from Rack Macros. - Extra preference options for changing some of Live's default behaviors such as - the ability to choose to have Track's automatically Arm upon selection. + the ability to choose to have track's automatically Arm upon selection. > This is a fork of ClyphX 2.6.2, which is licensed under the [GNU LGPL][lic] > and is [no longer being developed, updated or supported][note]. diff --git a/docs/macrobat_manual.md b/docs/macrobat_manual.md index f7721de..1c8e79f 100644 --- a/docs/macrobat_manual.md +++ b/docs/macrobat_manual.md @@ -103,25 +103,21 @@ the Track it is on.  You can have multiple Track Racks on a Track, but only one Macro should control a parameter.  So you shouldn't have two Macros that both control a Track's Volume for example. -**RACK NAME:** +**RACK NAME:** needs to start with `NK TRACK`. -The Rack's name needs to start with `NK TRACK`. +**MACRO NAMES:** can be the names of Track mixer parameters to control.  +`VOL` (Track Volume), `PAN` (Track Pan) or `SEND `_`x`_ (Track Send +where x is the Send letter). -**MACRO NAMES:** +**EXTRA FUNCTIONS:** The parameters that you've assigned to the Macros +can be reset to their default value by toggling the Track Rack's On/Off +switch (turn if off and then on again). -Macro Names can be the names of Track mixer parameters to control.  -*VOL* (Track Volume), *PAN* (Track Pan) or *SEND x* (Track Send where x -is the Send letter). - -**EXTRA FUNCTIONS:** - -The parameters that you've assigned to the Macros can be reset to their -default value by toggling the Track Rack's On/Off switch (turn if off -and then on again). - -_**NOTE:** This Rack type has no effect on Tracks with no Audio Output +> _**NOTE:** This Rack type has no effect on Tracks with no Audio Output or on the sub-tracks of a Drum or Instrument Track._ +--- + ## nK Receiver The Macros of a Receiver Rack (referred to as Receivers) can control the @@ -132,13 +128,9 @@ In order to accomplish this, the name of the Receiver and Sender both need to contain a unique Identifier.  The format of the Identifier is *(\*identifier\*).*  For example:  `(*ID*)` or `(*1*)` -**RACK NAME:** +**RACK NAME:** needs to start with `NK RECEIVER`. -The Rack's name needs to start with `NK RECEIVER`. - -**MACRO NAMES:** - -Macro Names can include Identifiers. +**MACRO NAMES:** can include Identifiers. > _**NOTE:**  The names of Senders and Receivers cannot contain any other parentheses aside from the ones used in the Identifier._ @@ -156,9 +148,7 @@ You can have multiple DR Racks on a Track, but only one Macro should control a parameter.  So you shouldn't have two Macros that both control a Simpler's Volume for example. -**RACK NAME:** - -The Rack's name needs to start with `NK DR`.  After this name, you will +**RACK NAME:** needs to start with `NK DR`.  After this name, you will specify the name or number of the Simpler/Sampler instance to control. To specify a name, you can see the names of each Simpler/Sampler @@ -173,35 +163,27 @@ case, you should specify the Simpler/Sampler number instead. To specify a number, look at the Chain List of the Drum Rack.  The first chain listed is 1, second is 2, etc. -**MACRO NAMES:** - -Macro names can be the name of a [Simpler/Sampler +**MACRO NAMES:** can be the name of a [Simpler/Sampler parameter](#5.-simpler/sampler-parameter-names) to control. -**EXTRA FUNCTIONS:** - -The parameters that you've assigned to the Macros can be reset to their -default value by toggling the DR Rack's On/Off switch (turn if off and -then on again). +**EXTRA FUNCTIONS:** The parameters that you've assigned to the Macros +can be reset to their default value by toggling the DR Rack's On/Off +switch (turn if off and then on again). --- ## nK Dr Multi The DR Multi Rack is basically the reverse of the [DR Rack](#nk-dr). It -can control the same parameter of multiple instances of Simpler/Sampler +can control the same parameter of multiple instances of Simpler/Sampler inside of a Drum Rack on the Track it is on. -**RACK NAME:** - -Rack name needs to start with `NK DR MULTI`.  After this name, you +**RACK NAME:** needs to start with `NK DR MULTI`. After this name, you will specify the name of the [Simpler/Sampler parameter](#5.-simpler/sampler-parameter-names) to control. -**MACRO NAMES:** - -Macro names can be the name or number of the Simpler/Sampler instance to -control.  The names you specify **are** case-sensitive. +**MACRO NAMES:** can be the name or number of the Simpler/Sampler +instance to control. The names you specify **are** case-sensitive. --- @@ -212,13 +194,9 @@ first 6 Sends respectively) of the selected Drum Rack Pad.  The DR Pad Mix Rack will operate on the first Drum Rack (that is not nested within another Rack) found on the Track. -**RACK NAME:** - -The Rack's name needs to start with `NK DR PAD MIX`. +**RACK NAME:** needs to start with `NK DR PAD MIX`. -**MACRO NAMES:** - -Doesn't apply. +**MACRO NAMES:** Doesn't apply. --- @@ -233,23 +211,17 @@ You can have multiple Chain Mix Racks on a Track, but only one Macro should control a parameter.  So you shouldn't have two Macros that both control a Chain's Volume for example. -**RACK NAME:** - -The Rack's name needs to start with `NK CHAIN MIX`.  After this name, +**RACK NAME:** needs to start with `NK CHAIN MIX`. After this name, you will specify the Chain mixer parameter to control.  `VOL` (Chain Volume), `PAN` (Chain Pan) or `MUTE` (Chain Mute). -**MACRO NAMES:** - -Macro Names can be the number of the Chain to operate on.  To find the -number of a Chain, look at the Chain List of the Rack.  The first chain -listed is 1, second is 2, etc. - -**EXTRA FUNCTIONS:** +**MACRO NAMES:** can be the number of the Chain to operate on. To find +the number of a Chain, look at the Chain List of the Rack. The first +chain listed is 1, second is 2, etc. -The parameters that you've assigned to the Macros can be reset to their -default value by toggling the Chain Mix Rack's On/Off switch (turn if -off and then on again). +**EXTRA FUNCTIONS:** The parameters that you've assigned to the Macros +can be reset to their default value by toggling the Chain Mix Rack's +On/Off switch (turn if off and then on again). --- @@ -260,13 +232,9 @@ control the Rack's Chain Selector.  The range of the Macro will change depending on the number of Chains in the Rack.  Of course, the Macro will have no functionality unless the Rack contains at least two Chains. -**RACK NAME:** - -The Rack's name needs to start with `NK CS`. - -**MACRO NAMES:** +**RACK NAME:** needs to start with `NK CS`. -Doesn't apply. +**MACRO NAMES:** Doesn't apply. --- @@ -278,21 +246,15 @@ Learn Rack in your Set and it can only exist on the Master Track. You can select a parameter to control by clicking on it with your mouse. -**RACK NAME:** +**RACK NAME:** needs to start with `NK LEARN`. -The Rack's name needs to start with `NK LEARN`. +**MACRO NAMES:** Doesn't apply. -**MACRO NAMES:** +**EXTRA FUNCTIONS:** The parameter that is assigned to the first Macro +can be reset to its default value by toggling the Learn Rack's On/Off +switch (turn if off and then on again). -Doesn't apply. - -**EXTRA FUNCTIONS:** - -The parameter that is assigned to the first Macro can be reset to its -default value by toggling the Learn Rack's On/Off switch (turn if off -and then on again). - -_**NOTE:**  Although all parameters in Live can be clicked on, not all +> _**NOTE:**  Although all parameters in Live can be clicked on, not all of them are classified as parameters that can be selected and so cannot be controlled with the Learn Rack.  As an example, none of the parameters of a Clip can be controlled.  Also, each time you select a @@ -319,26 +281,18 @@ they are located. RnR Racks will not affect each other or other Macrobat Racks (except for the [MIDI Rack](#nk-midi)), only other Devices on the Track/Device Chain. -**RACK NAME:** - -There are four types of RnR Racks, the names of which need to start with -the following text string: - -`NK RST` -- Reset the parameters of the Device to the right of this +**RACK NAME:** There are four types of RnR Racks, the names of which need +to start with the following text string: +- `NK RST` -- Reset the parameters of the Device to the right of this Rack. - -`NK RST ALL` -- Reset the parameters of all Devices on the Track/Device +- `NK RST ALL` -- Reset the parameters of all Devices on the Track/Device Chain. - -`NK RND` -- Randomize the parameters of the Device to the right of this +- `NK RND` -- Randomize the parameters of the Device to the right of this Rack. - -`NK RND ALL` -- Randomize the parameters of all Devices on the +- `NK RND ALL` -- Randomize the parameters of all Devices on the Track/Device Chain. -**MACRO NAMES:** - -Doesn't apply. +**MACRO NAMES:** Doesn't apply. > _**NOTE:**  Chain Selectors, on/off switches and multi-option controls (such as a filter type chooser) will not be reset/randomized._ @@ -350,18 +304,13 @@ Doesn't apply. The Macros on the Sidechain Rack can be connected to the output level of the Track it is on. -**RACK NAME:** - -The Rack's name needs to start with `NK SIDECHAIN`. +**RACK NAME:** needs to start with `NK SIDECHAIN`. -**MACRO NAMES:** +**MACRO NAMES:** To connect a Macro to the output level of the Track, +the Macro's name needs to start with `[SC]`. -To connect a Macro to the output level of the Track, the Macro's name -needs to start with `[SC]`. - -**EXTRA FUNCTIONS:** - -You can turn the sidechaining on/off with the Rack's On/Off switch. +**EXTRA FUNCTIONS:** You can turn the sidechaining on/off with the +Rack's On/Off switch. > _**IMPORTANT NOTE:**  Each movement of a Macro is considered an > undoable action in Live.  For that reason, when using a Sidechain Rack, @@ -375,18 +324,12 @@ You can turn the sidechaining on/off with the Rack's On/Off switch. The MIDI Rack allows you to send MIDI messages (Control Change, Program Change and SysEx) from the Macros. -**RACK NAME:** - -The Rack's name needs to start with `NK MIDI`. - -**MACRO NAMES:** - -Macro names can start with the following list of words/phrases: +**RACK NAME:** needs to start with `NK MIDI`. -`[CC`_**x**_`]` Control Change message where _**x**_ is the Control Change +**MACRO NAMES:** can start with the following list of words/phrases: +- `[CC`_**x**_`]` Control Change message where _**x**_ is the Control Change number to send.  This number should be in the range of 0 -- 127. - -`[PC]` -- Program Change message. +- `[PC]` -- Program Change message. The MIDI Rack can also send SysEx messages.  In order to access this functionality, you'll first need to create a SysEx List composed of the @@ -396,12 +339,10 @@ file](#userconfig-file). To access the SysEx messages from Macros, you'll use the Identifiers you specified in your SysEx List for the Macro Names -**EXTRA FUNCTIONS:** - -By default, the MIDI Rack will send out on MIDI Channel 1.  You can -override this by adding `[CH`_**x**_`]` to the end of the Rack's name where x -is the MIDI Channel number.  This number should be in the range of 1 - 16. -For example: `NK MIDI [CH6]` +**EXTRA FUNCTIONS:** By default, the MIDI Rack will send out on MIDI +Channel 1. You can override this by adding `[CH`_**x**_`]` to the end of +the Rack's name where x is the MIDI Channel number.  This number should +be in the range of 1 - 16. For example: `NK MIDI [CH6]` ### nK MIDI Rack Routing Options @@ -434,7 +375,7 @@ set to 'All Ins' or choose the loopback device as the input.  Leave the Track's input channel set to 'All Channels'.  Arm the Track or set it's Monitor to 'In'. -> _**NOTE**:  If you'd like to use SysEx and + > _**NOTE**:  If you'd like to use SysEx and still maintain the flexibility that Option B provides, you can use an application such as [Bome's MIDI Translator Pro](https://www.bome.com/products/miditranslator) to receive the SysEx @@ -462,20 +403,14 @@ mode.  You can turn them with a controller though. The first two Macros on the SCL Rack will control Push's Root Note and Scale Type respectively for use in Note Mode. -**RACK NAME:** - -The Rack's name needs to start with `NK SCL`. - -**MACRO NAMES:** - -Doesn't apply. +**RACK NAME:** needs to start with `NK SCL`. -**EXTRA FUNCTIONS:** +**MACRO NAMES:** Doesn't apply. -The Rack's name will show the name of the selected Root Note and Scale -Type. +**EXTRA FUNCTIONS:** The Rack's name will show the name of the selected +Root Note and Scale Type. -_**NOTE:** The Macros and Rack name will **not** update if the Root +> _**NOTE:** The Macros and Rack name will **not** update if the Root Note or Scale Type is changed from Push itself or from ClyphX's Push Actions._ @@ -488,8 +423,8 @@ which you'll find in the `./ClyphX/macrobat` folder. You can modify this file with any text editor (like Notepad or TextEdit).  The file itself includes instructions on how to modify it. -_**NOTE:**  You may see two files named user_config in the -ClyphX/macrobat folder.  One of them is a `*.pyc` file (you cannot +> _**NOTE:**  You may see two files named user_config in the +`ClyphX/macrobat` folder.  One of them is a `*.pyc` file (you cannot modify this) and the other is a `*.py` file.  You should modify the `*.py` file.*_ diff --git a/src/clyphx/actions/clip.py b/src/clyphx/actions/clip.py index bdaa7fb..f8447b8 100644 --- a/src/clyphx/actions/clip.py +++ b/src/clyphx/actions/clip.py @@ -28,7 +28,7 @@ from ..core.xcomponent import XComponent from ..core.live import Clip, Conversions from .clip_env_capture import XClipEnvCapture -from .clip_notes import ClipNotesMixin +from .clip_notes import NotesMixin from ..consts import (CLIP_GRID_STATES, R_QNTZ_STATES, WARP_MODES, ENV_TYPES, KEYWORDS, ONOFF, switch) @@ -36,7 +36,7 @@ log = logging.getLogger(__name__) -class XClipActions(XComponent, ClipNotesMixin): +class XClipActions(XComponent, NotesMixin): '''Clip-related actions. ''' __module__ = __name__ @@ -63,7 +63,8 @@ def dispatch_actions(self, cmd): 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) + args = [a.strip() for a in cmd.args.split() if a.strip()] + self.dispatch_clip_note_action(action[0], args) elif cmd.action_name.startswith('CLIP'): self.set_clip_on_off(action[0], scmd.track, scmd.xclip, cmd.args) diff --git a/src/clyphx/actions/clip_notes.py b/src/clyphx/actions/clip_notes.py index a47229e..25c3e70 100644 --- a/src/clyphx/actions/clip_notes.py +++ b/src/clyphx/actions/clip_notes.py @@ -15,83 +15,80 @@ # along with ClyphX. If not, see . from __future__ import absolute_import, unicode_literals -from builtins import object, dict, range -from typing import TYPE_CHECKING, NamedTuple, List, Text +from builtins import object, dict, range, tuple +from typing import NamedTuple, List, Text, Tuple import logging - -if TYPE_CHECKING: - from typing import Any, Union, Optional, Dict, Tuple - Note = Tuple[int, float, Any, Any, bool] - NoteActionSignature = Clip, Text, List[Note], List[Note] - import random -from ..consts import NOTE_NAMES, OCTAVE_NAMES + from ..core.live import Clip, get_random_int +from ..core.models import Pitch, pitch_range #, Note + +log = logging.getLogger(__name__) -# 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] +Note = Tuple[int, float, float, int, bool] -class ClipNotesMixin(object): +CmdData = NamedTuple('CmdData', [('clip', Clip), + ('notes_to_edit', List[Note]), + ('other_notes', List[Note]), + ('args', List[Text])]) + + +class NotesMixin(object): def dispatch_clip_note_action(self, clip, args): - # type: (Clip, Text) -> None + # type: (Clip, List[Text]) -> None '''Handle clip note actions.''' - from .consts import CLIP_NOTE_ACTIONS_CMD, CLIP_NOTE_ACTIONS_PREF + from .consts import NOTES_ACTIONS if clip.is_audio_clip: return - note_data = self.get_notes_to_operate_on(clip, args.strip()) - if note_data['notes_to_edit']: - newargs = (clip, note_data['args'], note_data['notes_to_edit'], note_data['other_notes']) - func = CLIP_NOTE_ACTIONS_CMD.get(note_data['args']) - if not func: - for k, v in CLIP_NOTE_ACTIONS_PREF.items(): - if note_data['args'].startswith(k): - func = v - break - else: - return - func(self, *newargs) - def get_notes_to_operate_on(self, clip, args=None): - # type: (Clip, Optional[Text]) -> Union[Dict[Text, List[Any]], Optional[Text]] + data = self.get_notes_to_edit(clip, args) + if data.notes_to_edit: + # TODO: pop arg + action = data.args[0] if data.args else None + try: + func = NOTES_ACTIONS[action] + except KeyError: + log.error("Note action not found in %s", data.args) + else: + edited = func(self, data) + if edited: + self.write_notes(data, edited) + + def get_notes_to_edit(self, clip, args): + # type: (Clip, List[Text]) -> CmdData '''Get notes within loop braces to operate on.''' notes_to_edit = [] other_notes = [] - new_args = None note_range = (0, 128) - pos_range = (clip.loop_start, clip.loop_end) + pos_range = None + if args: - new_args = [a.strip() for a in args.split()] - note_range = self.get_note_range(new_args[0]) - new_args.remove(new_args[0]) - if new_args and '@' in new_args[0]: - pos_range = self.get_pos_range(clip, new_args[0]) - new_args.remove(new_args[0]) - new_args = ' '.join(new_args) # type: ignore + note_range = self.get_note_range(args[0]) + args.pop(0) + if args and '@' in args[0]: + pos_range = self.get_pos_range(clip, args[0]) + args.pop(0) + pos_range = pos_range or (clip.loop_start, clip.loop_end) + clip.select_all_notes() all_notes = clip.get_selected_notes() clip.deselect_all_notes() + for n in all_notes: if note_range[0] <= n[0] < note_range[1] and pos_range[0] <= n[1] < pos_range[1]: notes_to_edit.append(n) else: other_notes.append(n) - return dict( - notes_to_edit = notes_to_edit, - other_notes = other_notes, - args = new_args, - ) + return CmdData(clip, notes_to_edit, other_notes, args) @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) + pos_range = None user_range = string.split('-') try: start = float(user_range[0].replace('@', '')) @@ -105,273 +102,154 @@ def get_pos_range(clip, string): pos_range = (start, float(user_range[1])) except: pass - return pos_range + return pos_range or (clip.loop_start, clip.loop_end) def get_note_range(self, string): # type: (Text) -> Tuple[int, int] '''Get note lane or range to operate on.''' + # TODO: check limit range in 128 note_range = (0, 128) string = string.replace('NOTES', '') if len(string) > 1: try: - note_range = self.get_note_range_from_string(string) + note_range = pitch_range(string) except: try: - start_note_name = self.get_note_name_from_string(string) - start_note_num = self.string_to_note(start_note_name) - note_range = (start_note_num, start_note_num + 1) - string = string.replace(start_note_name, '').strip() - if len(string) > 1 and string.startswith('-'): - string = string[1:] - end_note_name = self.get_note_name_from_string(string) - end_note_num = self.string_to_note(end_note_name) - if end_note_num > start_note_num: - note_range = (start_note_num, end_note_num + 1) + start_note = Pitch.first_note(string) + note_range = (start_note, start_note + 1) + string = string.replace(str(start_note), '').strip() + + end_note = Pitch.first_note(string[1:]) + if end_note > start_note_num: + note_range = (start_note_num, end_note + 1) except ValueError: pass return note_range @staticmethod - def get_note_range_from_string(string): - # type: (Text) -> Tuple[int, int] - '''Returns a note range (specified in ints) from string. - ''' - int_split = string.split('-') - start = int(int_split[0]) - try: - end = int(int_split[1]) + 1 - except IndexError: - end = start + 1 - if 0 <= start and end <= 128 and start < end: - return (start, end) - raise ValueError("Invalid range note: '{}'".format(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: - result = string[:2].strip() - if (result.endswith('#') or result.endswith('-')) and len(string) >= 3: - result = string[:3].strip() - if result.endswith('-') and len(string) >= 4: - result = string[:4].strip() - return result - raise ValueError("'{}' does not contain a note".format(string)) + def write_notes(data, edited): + # type: (CmdData, List[Note]) -> None + edited.extend(data.other_notes) + data.clip.select_all_notes() + data.clip.replace_selected_notes(tuple(edited)) + data.clip.deselect_all_notes() - @staticmethod - def string_to_note(string): - # type: (Text) -> Any - '''Get note value from string.''' - base_note = None - - for s in string: - if s in NOTE_NAMES: - base_note = NOTE_NAMES.index(s) - if base_note is not None and s == '#': - base_note += 1 - - if base_note is not None: - for o in OCTAVE_NAMES: - if o in string: - base_note = base_note + (OCTAVE_NAMES.index(o) * 12) - break - if 0 <= base_note < 128: - return base_note - - raise ValueError("Invalid note: '{}'".format(string)) - - @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) - clip.select_all_notes() - clip.replace_selected_notes(tuple(edited_notes)) - clip.deselect_all_notes() # region NOTE ACTIONS - def set_notes_on_off(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + def set_notes_on_off(self, data): + # type: (CmdData) -> List[Note] '''Toggles or turns note mute on/off.''' - edited_notes = [] - for n in notes_to_edit: - new_mute = False - if not args: - new_mute = not n[4] - elif args == 'ON': - new_mute = True - # TODO: check appending same tuple n times - edited_notes.append((n[0], n[1], n[2], n[3], new_mute)) - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) - - def do_note_gate_adjustment(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + if not data.args: + edited = [(n[0], n[1], n[2], n[3], not n[4]) for n in data.notes_to_edit] + else: + mute = data.args[0] == 'ON' + edited = [(n[0], n[1], n[2], n[3], mute) for n in data.notes_to_edit] + return edited + + def do_note_gate_adjustment(self, data): + # type: (CmdData) -> List[Note] '''Adjust note gate.''' - edited_notes = [] - factor = self.get_adjustment_factor(args.split()[1], True) - for n in notes_to_edit: + edited = [] + factor = self.get_adjustment_factor(data.args[1], True) + loop_end = data.clip.loop_end + for n in data.notes_to_edit: new_gate = n[2] + (factor * 0.03125) - if n[1] + new_gate > clip.loop_end or new_gate < 0.03125: - edited_notes = [] + if n[1] + new_gate > loop_end or new_gate < 0.03125: + edited = [] return else: - edited_notes.append((n[0], n[1], new_gate, n[3], n[4])) - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) + edited.append((n[0], n[1], new_gate, n[3], n[4])) + return edited - def do_note_nudge_adjustment(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + def do_note_nudge_adjustment(self, data): + # type: (CmdData) -> List[Note] '''Adjust note position.''' - edited_notes = [] - factor = self.get_adjustment_factor(args.split()[1], True) - for n in notes_to_edit: + edited = [] + factor = self.get_adjustment_factor(data.args[1], True) + for n in data.notes_to_edit: new_pos = n[1] + (factor * 0.03125) - if n[2] + new_pos > clip.loop_end or new_pos < 0.0: - edited_notes = [] + if n[2] + new_pos > data.clip.loop_end or new_pos < 0.0: + edited = [] return else: - edited_notes.append((n[0], new_pos, n[2], n[3], n[4])) - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) - - def do_note_velo_adjustment(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None - '''Adjust/set/randomize note velocity.''' - edited_notes = [] - args = args.replace('VELO ', '') - args = args.strip() - for n in notes_to_edit: - if args == 'RND': - # 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.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])) - else: - edited_notes = [] - return - else: - try: - edited_notes.append((n[0], n[1], n[2], float(args), n[4])) - except: - pass - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) + edited.append((n[0], new_pos, n[2], n[3], n[4])) + return edited - def do_pitch_scramble(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + def do_pitch_scramble(self, data): + # type: (CmdData) -> List[Note] '''Scrambles the pitches in the clip, but maintains rhythm.''' - edited_notes = [] - pitches = [n[0] for n in notes_to_edit] + pitches = [n[0] for n in data.notes_to_edit] random.shuffle(pitches) - for i in range(len(notes_to_edit)): - edited_notes.append(( - pitches[i], - notes_to_edit[i][1], - notes_to_edit[i][2], - notes_to_edit[i][3], - notes_to_edit[i][4], - )) - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) - - def do_position_scramble(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + notes = [tuple(n[1:5]) for n in data.notes_to_edit] + return [(pitches[i],) + n for i, n in enumerate(notes)] + + def do_position_scramble(self, data): + # type: (CmdData) -> List[Note] '''Scrambles the position of notes in the clip, but maintains pitches. ''' - edited_notes = [] - positions = [n[1] for n in notes_to_edit] + positions = [n[1] for n in data.notes_to_edit] random.shuffle(positions) - for i in range(len(notes_to_edit)): - edited_notes.append(( - notes_to_edit[i][0], - positions[i], - notes_to_edit[i][2], - notes_to_edit[i][3], - notes_to_edit[i][4], - )) - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) - - def do_note_reverse(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + return [(n[0], positions[i], n[2], n[3], n[4]) + for i, n in enumerate(data.notes_to_edit)] + + def do_note_reverse(self, data): + # type: (CmdData) -> List[Note] '''Reverse the position of notes.''' - edited_notes = [] - for n in notes_to_edit: - edited_notes.append( - (n[0], abs(clip.loop_end - (n[1] + n[2]) + clip.loop_start), n[2], n[3], n[4]) - ) - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) - - def do_note_invert(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + end, start = data.clip.loop_end, data.clip.loop_start + reverse = lambda n: abs(end - (n[1] + n[2]) + start) + return [(n[0], reverse(n), n[2], n[3], n[4]) for n in data.notes_to_edit] + + def do_note_invert(self, data): + # type: (CmdData) -> List[Note] ''' Inverts the pitch of notes.''' - edited_notes = [] - for n in notes_to_edit: - edited_notes.append((127 - n[0], n[1], n[2], n[3], n[4])) - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) - - def do_note_compress(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + return [(127 - n[0], n[1], n[2], n[3], n[4]) for n in data.notes_to_edit] + + def do_note_compress(self, data): + # type: (CmdData) -> List[Note] '''Compresses the position and duration of notes by half.''' - edited_notes = [] - for n in notes_to_edit: - edited_notes.append((n[0], n[1] / 2, n[2] / 2, n[3], n[4])) - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) - - def do_note_expand(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + return [(n[0], n[1] / 2, n[2] / 2, n[3], n[4]) for n in data.notes_to_edit] + + def do_note_expand(self, data): + # type: (CmdData) -> List[Note] '''Expands the position and duration of notes by 2.''' - edited_notes = [] - for n in notes_to_edit: - edited_notes.append((n[0], n[1] * 2, n[2] * 2, n[3], n[4])) - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) - - def do_note_split(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + return [(n[0], n[1] * 2, n[2] * 2, n[3], n[4]) for n in data.notes_to_edit] + + def do_note_split(self, data): + # type: (CmdData) -> List[Note] '''Split notes into 2 equal parts. ''' - edited_notes = [] + edited = [] - for n in notes_to_edit: + for n in data.notes_to_edit: if n[2] / 2 < 0.03125: return else: - edited_notes.append(n) - edited_notes.append((n[0], n[1] + (n[2] / 2), n[2] / 2, n[3], n[4])) + edited.append(n) + edited.append((n[0], n[1] + (n[2] / 2), n[2] / 2, n[3], n[4])) - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) + return edited - def do_note_combine(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + def do_note_combine(self, data): + # type: (CmdData) -> List[Note] '''Combine each consecutive set of 2 notes. ''' - edited_notes = [] + edited = [] current_note = [] check_next_instance = False - for n in notes_to_edit: - edited_notes.append(n) + for n in data.notes_to_edit: + edited.append(n) if current_note and check_next_instance: if current_note[0] == n[0] and current_note[1] + current_note[2] == n[1]: - edited_notes[edited_notes.index(current_note)] = [ + edited[edited.index(current_note)] = [ current_note[0], current_note[1], current_note[2] + n[2], current_note[3], current_note[4], ] - edited_notes.remove(n) + edited.remove(n) current_note = [] check_next_instance = False else: @@ -380,21 +258,52 @@ def do_note_combine(self, clip, args, notes_to_edit, other_notes): current_note = n check_next_instance = True - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) + return edited + + def do_note_velo_adjustment(self, data): + # type: (CmdData) -> List[Note] + '''Adjust/set/randomize note velocity.''' + edited = [] + arg = data.args[1] # data.args[0] == 'VELO' + + if arg == 'RND': + for n in data.notes_to_edit: + # FIXME: get_random_int + edited.append((n[0], n[1], n[2], get_random_int(64, 64), n[4])) + + elif arg.starswith(('<', '>')): + for n in data.notes_to_edit: + factor = self.get_adjustment_factor(arg) + new_velo = n[3] + factor + if 0 <= new_velo < 128: + edited.append((n[0], n[1], n[2], new_velo, n[4])) + else: + edited = [] + return + + elif arg in ('<<', '>>'): + return self.do_note_crescendo(data) + + else: + for n in data.notes_to_edit: + try: + edited.append((n[0], n[1], n[2], float(arg), n[4])) + except: + pass + + return edited - def do_note_crescendo(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + def do_note_crescendo(self, data): + # type: (CmdData) -> List[Note] '''Applies crescendo/decrescendo to notes.''' - edited_notes = [] + edited = [] last_pos = -1 pos_index = 0 new_pos = -1 new_index = 0 - sorted_notes = sorted(notes_to_edit, key=lambda note: note[1], reverse=False) - if args == 'VELO <<': - sorted_notes = sorted(notes_to_edit, key=lambda note: note[1], reverse=True) + reverse = data.args[1] == '<<' + sorted_notes = sorted(data.notes_to_edit, key=lambda n: n[1], reverse=reverse) for n in sorted_notes: if n[1] != last_pos: last_pos = n[1] @@ -403,12 +312,12 @@ def do_note_crescendo(self, clip, args, notes_to_edit, other_notes): if n[1] != new_pos: new_pos = n[1] new_index += 1 - edited_notes.append((n[0], n[1], n[2], ((128 / pos_index) * new_index) - 1, n[4])) - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) + edited.append((n[0], n[1], n[2], ((128 / pos_index) * new_index) - 1, n[4])) + return edited - def do_note_delete(self, clip, args, notes_to_edit, other_notes): - # type: (NoteActionSignature) -> None + def do_note_delete(self, data): + # type: (CmdData) -> None '''Delete notes.''' - self.write_all_notes(clip, [], other_notes) + # XXX: write here, edited would be evaluated to False + self.write_notes(data, []) # endregion diff --git a/src/clyphx/actions/consts.py b/src/clyphx/actions/consts.py index c9026eb..99ef33a 100644 --- a/src/clyphx/actions/consts.py +++ b/src/clyphx/actions/consts.py @@ -4,7 +4,7 @@ from .global_ import XGlobalActions from .track import XTrackActions -from .clip import XClipActions, ClipNotesMixin +from .clip import XClipActions, NotesMixin from .device import XDeviceActions from .dr import XDrActions @@ -155,31 +155,25 @@ TOSIMP = XClipActions.to_simpler, ) # type: Dict[Text, Callable] -CLIP_NOTE_ACTIONS_CMD = dict([ - ('REV', ClipNotesMixin.do_note_reverse), - ('INV', ClipNotesMixin.do_note_invert), - ('COMP', ClipNotesMixin.do_note_compress), - ('EXP', ClipNotesMixin.do_note_expand), - ('SCRN', ClipNotesMixin.do_pitch_scramble), - ('SCRP', ClipNotesMixin.do_position_scramble), - ('CMB', ClipNotesMixin.do_note_combine), - ('SPLIT', ClipNotesMixin.do_note_split), - ('DEL', ClipNotesMixin.do_note_delete), - ('VELO <<', ClipNotesMixin.do_note_crescendo), - ('VELO >>', ClipNotesMixin.do_note_crescendo), - ('ON', ClipNotesMixin.set_notes_on_off), - ('OFF', ClipNotesMixin.set_notes_on_off), - (None, ClipNotesMixin.set_notes_on_off), - ('', ClipNotesMixin.set_notes_on_off), -]) # type: Dict[Text, Callable[[Clip, Text, Sequence[Note], Sequence[Note]], None]] +NOTES_ACTIONS = dict([ + ('REV', NotesMixin.do_note_reverse), + ('INV', NotesMixin.do_note_invert), + ('COMP', NotesMixin.do_note_compress), + ('EXP', NotesMixin.do_note_expand), + ('GATE', NotesMixin.do_note_gate_adjustment), + ('NUDGE', NotesMixin.do_note_nudge_adjustment), + ('SCRN', NotesMixin.do_pitch_scramble), + ('SCRP', NotesMixin.do_position_scramble), + ('CMB', NotesMixin.do_note_combine), + ('SPLIT', NotesMixin.do_note_split), + ('DEL', NotesMixin.do_note_delete), + ('VELO', NotesMixin.do_note_velo_adjustment), + ('ON', NotesMixin.set_notes_on_off), + ('OFF', NotesMixin.set_notes_on_off), + (None, NotesMixin.set_notes_on_off), + ('', NotesMixin.set_notes_on_off), +]) # type: Dict[Text, Callable[[Clip, Any], List[None]]] -CLIP_NOTE_ACTIONS_PREF = dict([ - ('GATE <', ClipNotesMixin.do_note_gate_adjustment), - ('GATE >', ClipNotesMixin.do_note_gate_adjustment), - ('NUDGE <', ClipNotesMixin.do_note_nudge_adjustment), - ('NUDGE >', ClipNotesMixin.do_note_nudge_adjustment), - ('VELO', ClipNotesMixin.do_note_velo_adjustment), -]) # type: Dict[Text, Callable[[Clip, Text, Sequence[Note], Sequence[Note]], None]] DEVICE_ACTIONS = dict( CSEL = XDeviceActions.adjust_selected_chain, diff --git a/src/clyphx/consts.py b/src/clyphx/consts.py index 463108d..65bfd84 100644 --- a/src/clyphx/consts.py +++ b/src/clyphx/consts.py @@ -62,8 +62,6 @@ def switch(obj, attr, value, fallback=unset): NOTE_NAMES = ('C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B') -OCTAVE_NAMES = ('-2', '-1', '0', '1', '2', '3', '4', '5', '6', '7', '8') - ENV_TYPES = ( 'IRAMP', # Linear increasing ramp 'DRAMP', # Linear decreasing ramp diff --git a/src/clyphx/core/models.py b/src/clyphx/core/models.py index dae528d..dc2577f 100644 --- a/src/clyphx/core/models.py +++ b/src/clyphx/core/models.py @@ -6,15 +6,15 @@ from __future__ import absolute_import, unicode_literals from typing import TYPE_CHECKING, NamedTuple, List, Text, Optional, Any -from builtins import object +from builtins import object, tuple from ..consts import MIDI_STATUS from .utils import repr_slots -from .parse_notes import parse_pitch, pitch_note +from .parse_notes import parse_pitch, pitch_note, first_note, parse_range from .exceptions import InvalidParam if TYPE_CHECKING: - from typing import Dict, Union + from typing import Dict, Union, Tuple from numbers import Integral @@ -38,7 +38,7 @@ class Pitch(int): - '''Coverts either a numeric value or a note + octave string into a + '''Converts 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. @@ -57,6 +57,11 @@ def __new__(cls, value): return self raise ValueError(int(self)) + @classmethod + def first_note(cls, string): + '''Returns the first note found in the string.''' + return cls(first_note(string)) + @property def name(self): # type: () -> str @@ -107,11 +112,15 @@ def __str__(self): return '{}{}'.format(self.name, self.octave) -# TODO +def pitch_range(string): + # type: (Text) -> Tuple[Pitch, Pitch] + return tuple(Pitch[n] for n in parse_range(string)) + + Note = NamedTuple('Note', [('pitch', int), # TODO: Pitch ('start', float), - ('length', Any), - ('vel', Any), + ('length', float), + ('vel', int), ('mute', bool)]) diff --git a/src/clyphx/core/parse_notes.py b/src/clyphx/core/parse_notes.py index 60ebbe3..c20647f 100644 --- a/src/clyphx/core/parse_notes.py +++ b/src/clyphx/core/parse_notes.py @@ -27,11 +27,14 @@ ('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) +NOTE_INDEX = dict((v, k) for k, v in NOTE.items()) # returns sharp notes +VALUE_PTRN = r'(\d|[1-9]\d|1[0-1]\d|12[0-7])' +PITCH_PTRN = '({})({})'.format('|'.join(NOTE), '|'.join(OCTAVE)) +PITCH = re.compile(PITCH_PTRN, re.I) +RANGE = re.compile(r'{0}\-{0}'.format('({}|{})'.format(PITCH_PTRN, VALUE_PTRN)), re.I) def parse_pitch(string): @@ -50,6 +53,23 @@ def pitch_note(pitch): return NOTE_INDEX[pitch % 12], int(OCTAVE[pitch // 12]) -__all__ = ['parse_pitch', 'pitch_note'] +def first_note(string): + try: + return PITCH.search(string).group(0) + except AttributeError: + raise ValueError("No notes were found in '{}'".format(string)) + + +def parse_range(string): + try: + _, n1, o1, v1, _, n2, o2, v2 = RANGE.match(string).groups() + note1 = v1 if v1 else n1 + o1 + note2 = v2 if v2 else n2 + o2 + return note1, note2 + except Exception: + raise ValueError("Note range not found in '{}'".format(string)) + + +__all__ = ['parse_pitch', 'pitch_note', 'first_note', 'parse_range'] -del (NOTE, OCTAVE, NOTE_INDEX, PITCH) +del (NOTE, OCTAVE, NOTE_INDEX, VALUE_PTRN, PITCH_PTRN, PITCH, RANGE)