From 182e562d78dd170a8bff198e721c3cfebd001bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20Andr=C3=A9?= Date: Wed, 24 Feb 2021 17:12:57 +0100 Subject: [PATCH] refactor: user vars parsing and substitution feat: make midi args case-insensitive refactor: more decoupling between adapters refactor: map clip note actions with mixin methods --- .gitattributes | 10 +- .gitignore | 1 + .vscode/{.settings.json => .settings.jsonc} | 0 CONTRIBUTING.md | 15 +- README.md | 2 +- docs/macrobat_manual.md | 233 ++++---- src/clyphx/__init__.py | 2 +- src/clyphx/actions/clip.py | 526 +++--------------- src/clyphx/actions/clip_notes.py | 403 ++++++++++++++ src/clyphx/actions/consts.py | 48 +- src/clyphx/actions/device.py | 184 +++--- src/clyphx/actions/device_looper.py | 64 +++ src/clyphx/actions/dr.py | 24 +- src/clyphx/actions/global_.py | 26 +- src/clyphx/actions/push.py | 14 +- src/clyphx/actions/track.py | 287 +++++----- src/clyphx/clyphx.py | 158 ++---- src/clyphx/consts.py | 15 +- src/clyphx/core/exceptions.py | 8 +- src/clyphx/core/legacy.py | 24 +- src/clyphx/core/models.py | 10 +- src/clyphx/core/parse.py | 106 ++-- src/clyphx/core/parsing.py | 35 -- src/clyphx/core/utils.py | 5 +- src/clyphx/cs_linker.py | 5 +- src/clyphx/instant_doc.py | 2 - src/clyphx/m4l_browser.py | 22 +- src/clyphx/macrobat/consts.py | 39 ++ src/clyphx/macrobat/macrobat.py | 73 ++- src/clyphx/macrobat/midi_rack.py | 49 +- .../macrobat/parameter_rack_template.py | 86 +-- src/clyphx/macrobat/push_rack.py | 14 +- src/clyphx/macrobat/rnr_rack.py | 27 +- src/clyphx/macrobat/sidechain_rack.py | 15 +- src/clyphx/push_apc_combiner.py | 22 +- src/clyphx/triggers/__init__.py | 7 + src/clyphx/triggers/base.py | 41 ++ src/clyphx/triggers/clip.py | 79 +++ src/clyphx/triggers/control.py | 128 +++++ src/clyphx/triggers/cue.py | 97 ++++ src/clyphx/triggers/track.py | 101 ++++ src/clyphx/user_actions.py | 2 +- src/clyphx/user_config.py | 113 ++-- src/clyphx/xtriggers.py | 333 ----------- tests/test_lexer.py | 41 +- tests/test_parsing.py | 2 +- tools/win.ps1 | Bin 3666 -> 3858 bytes 47 files changed, 1880 insertions(+), 1618 deletions(-) rename .vscode/{.settings.json => .settings.jsonc} (100%) create mode 100644 src/clyphx/actions/clip_notes.py create mode 100644 src/clyphx/actions/device_looper.py delete mode 100644 src/clyphx/core/parsing.py create mode 100644 src/clyphx/macrobat/consts.py create mode 100644 src/clyphx/triggers/__init__.py create mode 100644 src/clyphx/triggers/base.py create mode 100644 src/clyphx/triggers/clip.py create mode 100644 src/clyphx/triggers/control.py create mode 100644 src/clyphx/triggers/cue.py create mode 100644 src/clyphx/triggers/track.py delete mode 100644 src/clyphx/xtriggers.py diff --git a/.gitattributes b/.gitattributes index 996584c..93a582f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,18 +1,14 @@ * text=auto eol=lf -# Git -.gitattributes text -.gitignore text - # Code *.py text diff=python -*.txt text *.pyc binary *.ps1 text encoding=utf-16le eol=crlf -*.json text +*.sh text diff=bash # Docs *.html text diff=html +*.md text diff=markdown *.pdf binary # Samples @@ -24,5 +20,5 @@ docs export-ignore tools export-ignore .gitattributes export-ignore .gitignore export-ignore -README.md export-ignore +*.md export-ignore setup.* export-ignore diff --git a/.gitignore b/.gitignore index d5e4440..64cebe0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .bin/ .env .vscode/settings.json +tests/fixtures/testproj/ diff --git a/.vscode/.settings.json b/.vscode/.settings.jsonc similarity index 100% rename from .vscode/.settings.json rename to .vscode/.settings.jsonc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf2172c..f458621 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,17 @@ # Contributing to ClyphX -## Vendorized packages +## Gotchas +- Live API objects must not be checked using `is None` since this would treat + lost weakrefs as valid. + ``` + # True both if obj represents a lost weakref or is None + obj == None + ``` +- Lists and dicts should be created with `list()` and `dict()`, as oppossed to + `[]` and `{}`, to make use of the `future` lib optimizations. + + +## Vendored packages | package | version | description | | ----------------- | ------- | ----------- | @@ -14,7 +25,7 @@ ## VSCode -Rename `.vscode/.settings.json` to `.vscode/settings.json`. +Rename `.vscode/.settings.jsonc` to `.vscode/settings.json`. ### VSCode Tasks diff --git a/README.md b/README.md index b9cf1ec..651f661 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ 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/MUTE ON; 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 ``` **ClyphX** also includes: diff --git a/docs/macrobat_manual.md b/docs/macrobat_manual.md index 41c720c..f7721de 100644 --- a/docs/macrobat_manual.md +++ b/docs/macrobat_manual.md @@ -4,7 +4,7 @@ [**2. Overview**](#2.-overview) -[**3. Rack Types**](#3.-rack-types) +[**3. Rack Types**](#3.-rack-types) [nK Track](#nk-track) [nK Receiver](#nk-receiver) [nK DR](#nk-dr) @@ -16,7 +16,7 @@ [nK RnR](#nk-rnr) [nK Sidechain](#nk-sidechain) [nK MIDI](#nk-midi) -[nK MIDI Rack Routing Options](#nk-midi-rack-routing-options) +    [nK MIDI Rack Routing Options](#nk-midi-rack-routing-options) [nK SCL](#nk-scl) [**4. UserConfig File**](#4.-userconfig-file) @@ -27,10 +27,10 @@ # 1. Changes In This Version -- Fixed issue where [DR Multi](#nk-dr-multi) didn\'t work correctly +- Fixed issue where [DR Multi](#nk-dr-multi) didn't work correctly with Macros containing Simpler/Sampler names. - [SCL](#nk-scl) now requires Live 9.5 or later. -- Added new [CS Rack](#nk-cs) for dynamically controlling a Rack\'s +- Added new [CS Rack](#nk-cs) for dynamically controlling a Rack's Chain Selector. - Added new [DR Pad Mix](#nk-dr-pad-mix) for controlling the Mixer parameters of the selected Drum Rack Pad. @@ -42,13 +42,13 @@ has Macros on it) in Live while maintaining the default functionality of the Rack.  Racks that access this additional functional are referred to as Macrobat Racks. -To access the additional functionality, the Rack\'s name needs to start +To access the additional functionality, the Rack's name needs to start with a particular word/phrase and, in most cases, the names of the -Rack\'s Macros need to start with particular words/phrases.  Rack/Macro -names shouldn\'t include special characters (like umlauts).  Also, +Rack's Macros need to start with particular words/phrases.  Rack/Macro +names shouldn't include special characters (like umlauts).  Also, naming is not case-sensitive except where noted. -After you\'ve changed the name of a Rack or Macro, you will need to +After you've changed the name of a Rack or Macro, you will need to reselect the Track the Rack is on in order for your changes to take effect.  You can reselect by selecting another Track and then reselecting the Track the Rack is on. @@ -82,7 +82,7 @@ Macrobat provides 12 Rack types: parameters (Volume, Pan and Mute) of the Chains of a Rack on the Track it is on. - [CS Rack](#nk-cs) -- This type allows you to use a Macro to - dynamically control the Rack\'s Chain Selector. + dynamically control the Rack's Chain Selector. - [Learn Rack](#nk-learn) -- This type allows you to use a Macro to control the last selected parameter in Live. - [RnR Racks](#nk-rnr) -- This type can Reset or Randomize parameters @@ -92,18 +92,20 @@ Macrobat provides 12 Rack types: - [MIDI Rack](#nk-midi) -- This type allows you to send MIDI messages (Control Changes, Program Changes and SysEx). - [SCL Rack](#nk-scl) -- This type allows you to use Macros to - control Push\'s Root Note and Scale Type for use in Note Mode. + control Push's Root Note and Scale Type for use in Note Mode. + +--- ## nK Track The Track Rack can control mixer parameters (Volume, Pan and Sends) of 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. +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:** -The Rack\'s name needs to start with `NK TRACK`. +The Rack's name needs to start with `NK TRACK`. **MACRO NAMES:** @@ -113,8 +115,8 @@ 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 +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 @@ -132,15 +134,17 @@ need to contain a unique Identifier.  The format of the Identifier is **RACK NAME:** -The Rack\'s name needs to start with `NK RECEIVER`. +The Rack's name needs to start with `NK RECEIVER`. **MACRO NAMES:** Macro Names can include Identifiers. -_**NOTE:**  The names of Senders and Receivers cannot contain any +> _**NOTE:**  The names of Senders and Receivers cannot contain any other parentheses aside from the ones used in the Identifier._ +--- + ## nK DR The DR Rack can control the parameters of an instance of Simpler/Sampler @@ -149,16 +153,16 @@ Track it is on.  The DR Rack will operate on the first Drum Rack (that is not nested within another Rack) found on the Track. 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. +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 +The Rack's 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 -instance by looking at the Drum Rack\'s pads.  You should enter the name +instance by looking at the Drum Rack's pads.  You should enter the name exactly as it appears (the name **is** case-sensitive).  For example:  *My Drum* @@ -176,15 +180,17 @@ 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 +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 identical to the [DR Rack](#nk-dr), but -in reverse.  It can control the same parameter of multiple instances of -Simpler/Sampler inside of a Drum Rack on the Track it is on. +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 +inside of a Drum Rack on the Track it is on. **RACK NAME:** @@ -197,6 +203,8 @@ parameter](#5.-simpler/sampler-parameter-names) to control. Macro names can be the name or number of the Simpler/Sampler instance to control.  The names you specify **are** case-sensitive. +--- + ## nK Dr Pad Mix The DR Pad Mix Rack can control the Mixer parameters (Volume, Pan and @@ -206,11 +214,13 @@ another Rack) found on the Track. **RACK NAME:** -The Rack\'s name needs to start with `NK DR PAD MIX`. +The Rack's name needs to start with `NK DR PAD MIX`. **MACRO NAMES:** -Doesn\'t apply. +Doesn't apply. + +--- ## nK Chain Mix @@ -220,12 +230,12 @@ on the first Rack (that is not nested within another Rack and is not a Midi Effects Rack) found on the Track. 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. +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, +The Rack's 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). @@ -237,24 +247,28 @@ listed is 1, second is 2, etc. **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 +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). +--- + ## nK CS The CS Rack allows you to use the first Macro of the Rack to dynamically -control the Rack\'s Chain Selector.  The range of the Macro will change +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`. +The Rack's name needs to start with `NK CS`. **MACRO NAMES:** -Doesn\'t apply. +Doesn't apply. + +--- ## nK Learn @@ -266,16 +280,16 @@ You can select a parameter to control by clicking on it with your mouse. **RACK NAME:** -The Rack\'s name needs to start with `NK LEARN`. +The Rack's name needs to start with `NK LEARN`. **MACRO NAMES:** -Doesn\'t apply. +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 +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 @@ -285,18 +299,22 @@ parameters of a Clip can be controlled.  Also, each time you select a new parameter, the first Macro on the Learn Rack will update, which will create an undo point (or multiple undo points)._ +--- + ## nK RnR RnR Racks can Reset or Randomize parameters of the Devices on the Track -they are on.  RnR Racks don\'t make use of Macros, they strictly use the -Rack\'s On/Off Switch.  To access the function of these Racks, just -change the state of the On/Off switch (if it\'s on, turn if off or vice -versa).   RnR Racks are position-sensitive.  This means that they way -the operate depends on where they are located.  If the RnR Rack is a top -level Rack (not nested inside of another Rack), the RnR Rack will apply -to other Devices on the Track.  If the RnR Rack is nested inside of -another Rack, the RnR Rack will apply to other Devices on the same -Device Chain. +they are on.  RnR Racks don't make use of Macros, they strictly use the +Rack's On/Off Switch.  To access the function of these Racks, just +change the state of the On/Off switch (if it's on, turn if off or vice +versa). + +RnR Racks are position-sensitive. The way they operate depends on where +they are located. +- If the RnR Rack is a top level Rack (not nested inside of another Rack), + it will apply to other Devices on the Track. +- If the RnR Rack is nested inside of another Rack, it will apply to other + Devices on the same Device Chain. 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. @@ -304,7 +322,7 @@ 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 list of words/phrases: +the following text string: `NK RST` -- Reset the parameters of the Device to the right of this Rack. @@ -315,16 +333,18 @@ Chain. `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. +Doesn't apply. -_**NOTE:**  Chain Selectors, on/off switches and multi-option controls +> _**NOTE:**  Chain Selectors, on/off switches and multi-option controls (such as a filter type chooser) will not be reset/randomized._ +--- + ## nK Sidechain The Macros on the Sidechain Rack can be connected to the output level of @@ -332,21 +352,23 @@ the Track it is on. **RACK NAME:** -The Rack\'s name needs to start with `NK SIDECHAIN`. +The Rack's name needs to start with `NK SIDECHAIN`. **MACRO NAMES:** -To connect a Macro to the output level of the Track, the Macro\'s name +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. +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, -you will not be able to reliably undo while the sidechaining is in -effect._ +> _**IMPORTANT NOTE:**  Each movement of a Macro is considered an +> undoable action in Live.  For that reason, when using a Sidechain Rack, +> you will not be able to reliably undo while the sidechaining is in +> effect._ + +--- ## nK MIDI @@ -355,7 +377,7 @@ Change and SysEx) from the Macros. **RACK NAME:** -The Rack\'s name needs to start with `NK MIDI`. +The Rack's name needs to start with `NK MIDI`. **MACRO NAMES:** @@ -367,60 +389,65 @@ number to send.  This number should be in the range of 0 -- 127. `[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 -SysEx messages you\'d like to send.  You\'ll do this in your [UserConfig +functionality, you'll first need to create a SysEx List composed of the +SysEx messages you'd like to send.  You'll do this in your [UserConfig file](#userconfig-file). -To access the SysEx messages from Macros, you\'ll use the Identifiers +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]` +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 -The MIDI data that the MIDI Rack sends can be used in a variety of ways +The MIDI data sent by the MIDI Rack can be used in a variety of ways via several routing options: -- **OPTION A** -- This is the only option useable with SysEx data.  Data -to external MIDI device.  In order to accomplish this, select the -external MIDI device as the Output of the ClyphX Control Surface. +- **OPTION A -- Data to external MIDI device.** +_[ This is the only option useable with SysEx data. ]_ + + In order to accomplish this, select the external MIDI device as the + Output of the ClyphX Control Surface. -_[ The next two options require a [loopback device such as MIDI Yoke or +> _[ The next two options require a [loopback device such as MIDI Yoke or IAC](https://help.ableton.com/hc/en-us/articles/209774225-How-to-setup-a-virtual-MIDI-bus) ]_ -- **OPTION B** -- This is the recommended option, but is not compatible -with SysEx (see [Note](#midi-routing-sysex)).  Data to loopback, -re-routed back into Live as Track data.  This option allows the MIDI -data to be sent into MIDI Tracks in Live.  From there, the data can be -rerouted via the MIDI Track\'s output routing and/or recorded. +- **OPTION B -- Data to loopback, re-routed back into Live as Track data.** +_[ This is the recommended option, but is not compatible with SysEx. +See [Note](#midi-routing-sysex). ]_ + + This option allows the MIDI data to be sent into MIDI Tracks in Live. +From there, the data can be rerouted via the MIDI Track's output routing +and/or recorded. In order to accomplish this, select the loopback device as the Output of the ClyphX Control Surface.  Turn the Track switch on for the loopback -device\'s input. +device's input. - For any MIDI Tracks you wish to use this with, leave the Track\'s input -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\'. + For any MIDI Tracks you wish to use this with, leave the Track's input +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 +application such as [Bome's MIDI Translator Pro](https://www.bome.com/products/miditranslator) to receive the SysEx from the loopback device and send it to your external MIDI device(s)._ -- **OPTION C** -- Data to loopback, re-routed back into Live as Remote -data.  This option allows the MIDI data to be sent back into Live as -Remote data (for MIDI mapping parameters).  In order to accomplish this, +- **OPTION C -- Data to loopback, re-routed back into Live as Remote data.** + + This option allows the MIDI data to be sent back into Live as Remote +data (for MIDI mapping parameters).  In order to accomplish this, select the loopback device as the Output of the ClyphX Control Surface.  -Turn the Remote switch on for the loopback device\'s input. +Turn the Remote switch on for the loopback device's input. - Live\'s Remote facilities do not support PCs or SysEx, so you should not + Live's Remote facilities do not support PCs or SysEx, so you should not set up a Macro to send a PC or SysEx when using Option C.  You should use CCs only. @@ -428,33 +455,35 @@ use CCs only. as you cannot turn the Macros with your mouse while in MIDI mapping mode.  You can turn them with a controller though. +--- + ## nK SCL -The first two Macros on the SCL Rack will control Push\'s Root Note and +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`. +The Rack's name needs to start with `NK SCL`. **MACRO NAMES:** -Doesn\'t apply. +Doesn't apply. **EXTRA FUNCTIONS:** -The Rack\'s name will show the name of the selected Root Note and Scale +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 or Scale Type is changed from Push itself or from ClyphX\'s Push +Note or Scale Type is changed from Push itself or from ClyphX's Push Actions._ # 4. UserConfig File -If you\'d like to send SysEx data with the [MIDI Rack](#nk-midi), -you\'ll need to create a SysEx List in the file named `user_config.py`, -which you\'ll find in the `./ClyphX/macrobat` folder. +If you'd like to send SysEx data with the [MIDI Rack](#nk-midi), +you'll need to create a SysEx List in the file named `user_config.py`, +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. @@ -466,7 +495,7 @@ modify this) and the other is a `*.py` file.  You should modify the # 5. Simpler/Sampler Parameter Names -If following charts show the names of Simpler and Sampler parameters for +The following charts show the names of Simpler and Sampler parameters for use with the [DR Multi Rack](#nk-dr-multi). ## Simpler Parameter Names @@ -509,18 +538,16 @@ use with the [DR Multi Rack](#nk-dr-multi). Copyright 2013-2017 [nativeKONTROL](https://nativekontrol.com/).  All rights reserved. -This document, as well as the software described in it, is provided +_This document, as well as the software described in it, is provided under license and may be used or copied only in accordance with the terms of this license.  The content of this document is furnished for informational use only, is subject to change without notice, and should not be construed as a commitment by its authors.  Every effort has been made to ensure that the information in this document is accurate.  The authors assume no responsibility or liability for any errors or -inaccuracies that may appear in this document. +inaccuracies that may appear in this document._ -All product and company names mentioned in this document, as well as the +_All product and company names mentioned in this document, as well as the software it describes, are trademarks or registered trademarks of their respective owners.  This software is solely endorsed and supported by -nativeKONTROL. - - +nativeKONTROL._ diff --git a/src/clyphx/__init__.py b/src/clyphx/__init__.py index 1842bde..5dc0dab 100644 --- a/src/clyphx/__init__.py +++ b/src/clyphx/__init__.py @@ -15,7 +15,7 @@ # along with ClyphX. If not, see . from __future__ import absolute_import, unicode_literals -__version__ = 2, 7, 2 +__version__ = 3, 0, 0, 'dev3' import sys import os diff --git a/src/clyphx/actions/clip.py b/src/clyphx/actions/clip.py index 2ee33d6..c66c97a 100644 --- a/src/clyphx/actions/clip.py +++ b/src/clyphx/actions/clip.py @@ -1,3 +1,4 @@ +# pyright: reportMissingTypeStubs=true # -*- coding: utf-8 -*- # This file is part of ClyphX. # @@ -15,32 +16,27 @@ # along with ClyphX. If not, see . from __future__ import absolute_import, unicode_literals -from builtins import super, dict, range +from builtins import super, range from typing import TYPE_CHECKING import logging if TYPE_CHECKING: - from typing import ( - Any, Union, Optional, Text, Dict, - Iterable, Sequence, List, Tuple, - ) + from typing import Any, Optional, Text, Dict, List, Tuple from ..core.live import DeviceParameter, Track from ..core.legacy import _DispatchCommand -import random from ..core.xcomponent import XComponent -from ..core.live import Clip, get_random_int +from ..core.live import Clip from .clip_env_capture import XClipEnvCapture -from ..consts import KEYWORDS, ONOFF +from .clip_notes import ClipNotesMixin from ..consts import (CLIP_GRID_STATES, R_QNTZ_STATES, WARP_MODES, ENV_TYPES, - NOTE_NAMES, OCTAVE_NAMES) + KEYWORDS, ONOFF, switch) log = logging.getLogger(__name__) -# pyright: reportMissingTypeStubs=true -class XClipActions(XComponent): +class XClipActions(XComponent, ClipNotesMixin): '''Clip-related actions. ''' __module__ = __name__ @@ -64,11 +60,11 @@ def dispatch_actions(self, cmd): if len(action) > 1: clip_args = action[1] if clip_args and clip_args.split()[0] in CLIP_ACTIONS: - fn = CLIP_ACTIONS[clip_args.split()[0]] - fn(self, action[0], scmd.track, scmd.xclip, scmd.ident, - clip_args.replace(clip_args.split()[0], '')) + func = CLIP_ACTIONS[clip_args.split()[0]] + func(self, action[0], scmd.track, scmd.xclip, scmd.ident, + clip_args.replace(clip_args.split()[0], '')) elif clip_args and clip_args.split()[0].startswith('NOTES'): - self.do_clip_note_action(action[0], None, None, None, cmd.args) + 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) @@ -118,13 +114,14 @@ def set_clip_name(self, clip, track, xclip, ident, args): def set_clip_on_off(self, clip, track, xclip, ident, value=None): # type: (Clip, None, 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 '''Toggles or turns clip warp on/off.''' if clip.is_audio_clip: - clip.warping = KEYWORDS.get(value, not clip.warping) + switch(clip, 'warping', value) def adjust_time_signature(self, clip, track, xclip, ident, args): # type: (Clip, None, None, None, Text) -> None @@ -134,8 +131,8 @@ def adjust_time_signature(self, clip, track, xclip, ident, args): num, denom = args.split('/') clip.signature_numerator = int(num) clip.signature_denominator = int(denom) - except: - pass + 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 @@ -148,8 +145,8 @@ def adjust_detune(self, clip, track, xclip, ident, args): else: try: clip.pitch_fine = int(args) - except: - pass + 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 @@ -167,8 +164,26 @@ def adjust_transpose(self, clip, track, xclip, ident, args): if clip.is_audio_clip: try: clip.pitch_coarse = int(args) - except: - pass + except Exception as e: + log.error('Failed to adjust transpose: %r', e) + + def do_note_pitch_adjustment(self, clip, factor): + # type: (Clip, Any) -> None + '''Adjust note pitch. This isn't a note action, it's called via + Clip Semi. + ''' + edited_notes = [] + note_data = self.get_notes_to_operate_on(clip) + if note_data['notes_to_edit']: + for n in note_data['notes_to_edit']: + new_pitch = n[0] + factor + if 0 <= new_pitch < 128: + edited_notes.append((new_pitch, n[1], n[2], n[3], n[4])) + else: + edited_notes = [] + return + 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 @@ -183,8 +198,8 @@ def adjust_gain(self, clip, track, xclip, ident, args): else: try: clip.gain = int(args) * float(1.0 / 127.0) - except: - pass + 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 @@ -204,8 +219,8 @@ def adjust_start(self, clip, track, xclip, ident, args): clip.start_marker = float(args) else: clip.loop_start = float(args) - except: - pass + 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 @@ -217,8 +232,8 @@ def adjust_loop_start(self, clip, track, xclip, ident, args): else: try: clip.loop_start = float(args) - except: - pass + 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 @@ -240,8 +255,8 @@ def adjust_end(self, clip, track, xclip, ident, args): clip.end_marker = float(args) else: clip.loop_end = float(args) - except: - pass + 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 @@ -254,8 +269,8 @@ def adjust_loop_end(self, clip, track, xclip, ident, args): else: try: clip.loop_end = float(args) - except: - pass + 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 @@ -276,8 +291,8 @@ def adjust_cue_point(self, clip, track, xclip, ident, args): clip.looping = True if clip != xclip: clip.fire() - except: - pass + except Exception as e: + log.error('Failed to adjust cue point: %r', e) else: if isinstance(xclip, Clip): xclip.name = '{} {}'.format(xclip.name.strip(), clip.loop_start) @@ -311,7 +326,7 @@ def adjust_grid_quantization(self, clip, track, xclip, ident, args): def set_triplet_grid(self, clip, track, xclip, ident, args): # type: (Clip, None, None, None, Text) -> None '''Toggles or turns triplet grid on or off.''' - clip.view.grid_is_triplet = KEYWORDS.get(args, not clip.view.grid_is_triplet) + switch(clip.view, 'grid_is_triplet', args) def capture_to_envelope(self, clip, track, xclip, ident, args): # type: (Clip, Any, None, None, Text) -> None @@ -512,8 +527,8 @@ def duplicate_clip_content(self, clip, track, xclip, ident, args): if clip.is_midi_clip: try: clip.duplicate_loop() - except: - pass + 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 @@ -527,8 +542,8 @@ def duplicate_clip(self, clip, track, xclip, ident, args): ''' try: track.duplicate_clip_slot(list(track.clip_slots).index(clip.canonical_parent)) - except: - pass + 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 @@ -554,8 +569,8 @@ def chop_clip(self, clip, track, xclip, ident, args): dupe.start_marker = dupe_start dupe.loop_start = dupe_start dupe.name = '{}-{}'.format(clip.name, i + 1) - except: - pass + 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 @@ -585,14 +600,31 @@ def split_clip(self, clip, track, xclip, ident, args): dupe.start_marker = dupe_start dupe.loop_start = dupe_start dupe.name = '{}-{}'.format(clip.name, i + 1) - except: - pass + except Exception as e: + log.error('Failed to split clip: %r', e) + + def get_clip_stats(self, clip): + # type: (Clip) -> Dict[Text, Any] + '''Get real length and end of looping clip.''' + clip.looping = 0 + length = clip.length + end = clip.loop_end + clip.looping = 1 + loop_length = clip.loop_end - clip.loop_start + return dict( + clip_length = length, + real_end = end, + loop_length = loop_length, + ) +# 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 '''Handle clip loop actions.''' args = args.strip() - if not args or args in KEYWORDS: + if not args or args.upper() in KEYWORDS: self.set_loop_on_off(clip, args) else: if args.startswith('START'): @@ -617,9 +649,10 @@ def do_clip_loop_action(self, clip, track, xclip, ident, args): new_end = clip_stats['real_end'] elif args.startswith('*'): try: - new_end = (clip.loop_end - clip_stats['loop_length']) + (clip_stats['loop_length'] * float(args[1:])) - except: - pass + 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) else: self.do_loop_set(clip, args, clip_stats) return @@ -667,8 +700,8 @@ def do_loop_set(self, clip, args, clip_stats): start += bar - distance end = start + (bar * bars_to_loop) self.set_new_loop_position(clip, start, end, clip_stats) - except: - pass + except Exception as e: + log.error('Failed to do loop set: %r', e) def set_new_loop_position(self, clip, new_start, new_end, clip_stats): # type: (Clip, float, float, Dict[Text, Any]) -> None @@ -683,397 +716,4 @@ def set_new_loop_position(self, clip, new_start, new_end, clip_stats): else: clip.loop_start = new_start clip.loop_end = new_end - - def do_clip_note_action(self, clip, track, xclip, ident, args): - # type: (Clip, None, None, None, Text) -> None - '''Handle clip note 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']) - if not note_data['args'] or note_data['args'] in KEYWORDS: - self.set_notes_on_off(*newargs) - elif note_data['args'] == 'REV': - self.do_note_reverse(*newargs) - elif note_data['args'] == 'INV': - self.do_note_invert(*newargs) - elif note_data['args'] == 'COMP': - self.do_note_compress(*newargs) - elif note_data['args'] == 'EXP': - self.do_note_expand(*newargs) - elif note_data['args'] == 'SCRN': - self.do_pitch_scramble(*newargs) - elif note_data['args'] == 'SCRP': - self.do_position_scramble(*newargs) - elif note_data['args'] in ('CMB', 'SPLIT'): - self.do_note_split_or_combine(*newargs) - elif note_data['args'].startswith(('GATE <', 'GATE >')): - self.do_note_gate_adjustment(*newargs) - elif note_data['args'].startswith(('NUDGE <', 'NUDGE >')): - self.do_note_nudge_adjustment(*newargs) - elif note_data['args'] == 'DEL': - self.do_note_delete(*newargs) - elif note_data['args'] in ('VELO <<', 'VELO >>'): - self.do_note_crescendo(*newargs) - elif note_data['args'].startswith('VELO'): - self.do_note_velo_adjustment(*newargs) - - def set_notes_on_off(self, clip, args, notes_to_edit, other_notes): - '''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_pitch_adjustment(self, clip, factor): - '''Adjust note pitch. This isn't a note action, it's called via - Clip Semi. - ''' - edited_notes = [] - note_data = self.get_notes_to_operate_on(clip) - if note_data['notes_to_edit']: - for n in note_data['notes_to_edit']: - new_pitch = n[0] + factor - if 0 <= new_pitch < 128: - edited_notes.append((new_pitch, n[1], n[2], n[3], n[4])) - else: - edited_notes = [] - return - if edited_notes: - self.write_all_notes(clip, edited_notes, note_data['other_notes']) - - def do_note_gate_adjustment(self, clip, args, notes_to_edit, other_notes): - '''Adjust note gate.''' - edited_notes = [] - factor = self._parent.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: - edited_notes = [] - 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) - - def do_note_nudge_adjustment(self, clip, args, notes_to_edit, other_notes): - '''Adjust note position.''' - edited_notes = [] - factor = self._parent.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: - edited_notes = [] - 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: (Clip, Text, Iterable[Tuple[Any, Any, Any, Any, Any]], Iterable[Tuple[Any, Any, Any, Any, Any]]) -> 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._parent.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) - - def do_pitch_scramble(self, clip, args, notes_to_edit, other_notes): - '''Scrambles the pitches in the clip, but maintains rhythm.''' - edited_notes = [] - pitches = [n[0] for n in 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): - '''Scrambles the position of notes in the clip, but maintains pitches. - ''' - edited_notes = [] - positions = [n[1] for n in 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): - '''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): - ''' 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): - '''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): - '''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_or_combine(self, clip, args, notes_to_edit, other_notes): - '''Split notes into 2 equal parts or combine each consecutive set of 2 - notes. - ''' - edited_notes = [] - current_note = [] - check_next_instance = False - - if args == 'SPLIT': - for n in 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])) - else: - for n in notes_to_edit: - edited_notes.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)] = [ - current_note[0], - current_note[1], - current_note[2] + n[2], - current_note[3], - current_note[4], - ] - edited_notes.remove(n) - current_note = [] - check_next_instance = False - else: - current_note = n - else: - current_note = n - check_next_instance = True - if edited_notes: - self.write_all_notes(clip, edited_notes, other_notes) - - def do_note_crescendo(self, clip, args, notes_to_edit, other_notes): - '''Applies crescendo/decrescendo to notes.''' - edited_notes = [] - 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) - for n in sorted_notes: - if n[1] != last_pos: - last_pos = n[1] - pos_index += 1 - for n in sorted_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) - - def do_note_delete(self, clip, args, notes_to_edit, other_notes): - '''Delete notes.''' - self.write_all_notes(clip, [], other_notes) - - def get_clip_stats(self, clip): - # type: (Clip) -> Dict[Text, Any] - '''Get real length and end of looping clip.''' - clip.looping = 0 - length = clip.length - end = clip.loop_end - clip.looping = 1 - loop_length = clip.loop_end - clip.loop_start - return dict( - clip_length = length, - real_end = end, - loop_length = loop_length, - ) - - def get_notes_to_operate_on(self, clip, args=None): - # type: (Clip, Optional[Text]) -> Union[Dict[Text, Sequence[Any]], Optional[Text]] - '''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) - 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 - 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, - ) - - def get_pos_range(self, clip, string): - # type: (Clip, Text) -> Tuple[float, float] - '''Get note position or range to operate on.''' - pos_range = (clip.loop_start, clip.loop_end) - user_range = string.split('-') - try: - start = float(user_range[0].replace('@', '')) - except: - pass - else: - if start >= 0.0: - pos_range = (start, start) - if len(user_range) > 1: - try: - pos_range = (start, float(user_range[1])) - except: - pass - return pos_range - - def get_note_range(self, string): - # type: (Text) -> Tuple[int, int] - '''Get note lane or range to operate on.''' - note_range = (0, 128) - string = string.replace('NOTES', '') - if len(string) > 1: - try: - note_range = self.get_note_range_from_string(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) - except ValueError: - pass - return note_range - - def get_note_range_from_string(self, 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("'{}' is not a valid range note.") - - def get_note_name_from_string(self, string): - # type: (Text) -> Text - '''Get the first note name specified in the given string.''' - if len(string) >= 2: - result = string[0:2].strip() - if (result.endswith('#') or result.endswith('-')) and len(string) >= 3: - result = string[0:3].strip() - if result.endswith('-') and len(string) >= 4: - result = string[0:4].strip() - return result - raise ValueError("'{}' does not contain a note".format(string)) - - def string_to_note(self, 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("'{}' is not a valid note".format(string)) - - def write_all_notes(self, clip, edited_notes, other_notes): - # type: (Clip, List[Tuple[Any, Any, Any, Any, Any]], 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() +# endregion diff --git a/src/clyphx/actions/clip_notes.py b/src/clyphx/actions/clip_notes.py new file mode 100644 index 0000000..50983f4 --- /dev/null +++ b/src/clyphx/actions/clip_notes.py @@ -0,0 +1,403 @@ +# -*- coding: utf-8 -*- +# This file is part of ClyphX. +# +# ClyphX is free software: you can redistribute it and/or modify it under the +# 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 +# more details. +# +# 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 +from builtins import object, dict, range +from typing import TYPE_CHECKING +import logging + +if TYPE_CHECKING: + from typing import Any, Union, Optional, Text, Dict, List, 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 + + +class ClipNotesMixin(object): + + def dispatch_clip_note_action(self, clip, args): + # type: (Clip, Text) -> None + '''Handle clip note actions.''' + from .consts import CLIP_NOTE_ACTIONS_CMD, CLIP_NOTE_ACTIONS_PREF + + 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]] + '''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) + 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 + 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, + ) + + def get_pos_range(self, clip, string): + # type: (Clip, Text) -> Tuple[float, float] + '''Get note position or range to operate on.''' + pos_range = (clip.loop_start, clip.loop_end) + user_range = string.split('-') + try: + start = float(user_range[0].replace('@', '')) + except: + pass + else: + if start >= 0.0: + pos_range = (start, start) + if len(user_range) > 1: + try: + pos_range = (start, float(user_range[1])) + except: + pass + return pos_range + + def get_note_range(self, string): + # type: (Text) -> Tuple[int, int] + '''Get note lane or range to operate on.''' + note_range = (0, 128) + string = string.replace('NOTES', '') + if len(string) > 1: + try: + note_range = self.get_note_range_from_string(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) + except ValueError: + pass + return note_range + + def get_note_range_from_string(self, 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)) + + def get_note_name_from_string(self, 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 string_to_note(self, 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)) + + def write_all_notes(self, 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 + '''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 + '''Adjust note gate.''' + edited_notes = [] + factor = self._parent.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: + edited_notes = [] + 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) + + 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) + 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: + edited_notes = [] + 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._parent.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) + + def do_pitch_scramble(self, clip, args, notes_to_edit, other_notes): + # type: (NoteActionSignature) -> None + '''Scrambles the pitches in the clip, but maintains rhythm.''' + edited_notes = [] + pitches = [n[0] for n in 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 + '''Scrambles the position of notes in the clip, but maintains + pitches. + ''' + edited_notes = [] + positions = [n[1] for n in 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 + '''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 + ''' 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 + '''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 + '''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 + '''Split notes into 2 equal parts. + ''' + edited_notes = [] + + for n in 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])) + + if edited_notes: + self.write_all_notes(clip, edited_notes, other_notes) + + def do_note_combine(self, clip, args, notes_to_edit, other_notes): + # type: (NoteActionSignature) -> None + '''Combine each consecutive set of 2 notes. + ''' + edited_notes = [] + current_note = [] + check_next_instance = False + + for n in notes_to_edit: + edited_notes.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)] = [ + current_note[0], + current_note[1], + current_note[2] + n[2], + current_note[3], + current_note[4], + ] + edited_notes.remove(n) + current_note = [] + check_next_instance = False + else: + current_note = n + else: + current_note = n + check_next_instance = True + + if edited_notes: + self.write_all_notes(clip, edited_notes, other_notes) + + def do_note_crescendo(self, clip, args, notes_to_edit, other_notes): + # type: (NoteActionSignature) -> None + '''Applies crescendo/decrescendo to notes.''' + edited_notes = [] + 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) + for n in sorted_notes: + if n[1] != last_pos: + last_pos = n[1] + pos_index += 1 + for n in sorted_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) + + def do_note_delete(self, clip, args, notes_to_edit, other_notes): + # type: (NoteActionSignature) -> None + '''Delete notes.''' + self.write_all_notes(clip, [], other_notes) +# endregion diff --git a/src/clyphx/actions/consts.py b/src/clyphx/actions/consts.py index 41e3617..591e965 100644 --- a/src/clyphx/actions/consts.py +++ b/src/clyphx/actions/consts.py @@ -4,14 +4,16 @@ from .global_ import XGlobalActions from .track import XTrackActions -from .clip import XClipActions +from .clip import XClipActions, ClipNotesMixin from .device import XDeviceActions from .dr import XDrActions if TYPE_CHECKING: - from typing import Any, Callable, Dict, Text, Optional, Sequence + from typing import Any, Callable, Dict, Text, Optional, Sequence, Tuple from ..core.live import Device + Note = Tuple[int, float, Any, Any, bool] + # NOTE: Action names and their corresponding values can't contain a '/' or '-' # within the first four chars like this 'EX/ONE', but 'EXMP/ONE' is okay. @@ -143,6 +145,32 @@ NAME = XClipActions.set_clip_name, ) # 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]] + +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, CS = XDeviceActions.adjust_chain_selector, @@ -150,14 +178,14 @@ RND = XDeviceActions.randomize_params, SEL = XDeviceActions.select_device, SET = XDeviceActions.set_all_params, - P1 = XDeviceActions.adjust_best_of_bank_param, - P2 = XDeviceActions.adjust_best_of_bank_param, - P3 = XDeviceActions.adjust_best_of_bank_param, - P4 = XDeviceActions.adjust_best_of_bank_param, - P5 = XDeviceActions.adjust_best_of_bank_param, - P6 = XDeviceActions.adjust_best_of_bank_param, - P7 = XDeviceActions.adjust_best_of_bank_param, - P8 = XDeviceActions.adjust_best_of_bank_param, + P1 = XDeviceActions.adjust_bob_param, + P2 = XDeviceActions.adjust_bob_param, + P3 = XDeviceActions.adjust_bob_param, + P4 = XDeviceActions.adjust_bob_param, + P5 = XDeviceActions.adjust_bob_param, + P6 = XDeviceActions.adjust_bob_param, + P7 = XDeviceActions.adjust_bob_param, + P8 = XDeviceActions.adjust_bob_param, B1 = XDeviceActions.adjust_banked_param, B2 = XDeviceActions.adjust_banked_param, B3 = XDeviceActions.adjust_banked_param, diff --git a/src/clyphx/actions/device.py b/src/clyphx/actions/device.py index 1e0374a..9127a3b 100644 --- a/src/clyphx/actions/device.py +++ b/src/clyphx/actions/device.py @@ -30,12 +30,13 @@ ) from ..core.xcomponent import XComponent from ..core.live import Clip, get_random_int -from ..consts import KEYWORDS, LOOPER_STATES +from ..consts import switch +from .device_looper import LooperMixin log = logging.getLogger(__name__) -class XDeviceActions(XComponent): +class XDeviceActions(XComponent, LooperMixin): '''Device and Looper actions. ''' __module__ = __name__ @@ -57,20 +58,32 @@ def dispatch_device_actions(self, cmd): for scmd in cmd: _args = scmd.track, scmd.action_name, scmd.args device_action = self._parent.get_device_to_operate_on(*_args) - device_args = None if device_action[0]: - if len(device_action) > 1: + try: device_args = device_action[1] + except IndexError: + device_args = None + if device_args and device_args.split()[0] in DEVICE_ACTIONS: - fn = DEVICE_ACTIONS[device_args.split()[0]] - fn(self, device_action[0], scmd.track, scmd.xclip, scmd.ident, device_args) + func = DEVICE_ACTIONS[device_args.split()[0]] + 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 cmd.action_name.startswith('DEV'): self.set_device_on_off(device_action[0], device_args) - def set_all_params(self, device, track, xclip, ident, args): - # type: (Device, None, Clip, None, Text) -> None + 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 + self.application().view.show_view('Detail') + self.application().view.show_view('Detail/DeviceChain') + self.song().view.select_device(device) + + def set_all_params(self, device, track, xclip, args): + # type: (Device, None, Clip, Text) -> None '''Set the value of all macros in a rack in one go. So don't need to use a whole string of DEV Px actions to do this. Can also capture the values of macros and store them in X-Clip name @@ -86,16 +99,15 @@ def set_all_params(self, device, track, xclip, ident, args): device.parameters[i + 1], param_values[i].strip() ) - else: - if isinstance(xclip, Clip): - assign_string = '{} '.format(xclip.name) - for param in device.parameters: - if 'Macro' in param.original_name: - assign_string += '{} '.format(int(param.value)) - xclip.name = assign_string + elif isinstance(xclip, Clip): + assign_string = '{} '.format(xclip.name) + for param in device.parameters: + if 'Macro' in param.original_name: + assign_string += '{} '.format(int(param.value)) + xclip.name = assign_string - def adjust_selected_chain(self, device, track, xclip, ident, args): - # type: (Device, None, None, None, Text) -> None + def adjust_selected_chain(self, device, track, xclip, args): + # type: (Device, None, None, Text) -> None '''Adjust the selected chain in a rack.''' if device.can_have_chains and device.chains: args = args.replace('CSEL', '', 1).strip() @@ -110,8 +122,8 @@ def adjust_selected_chain(self, device, track, xclip, ident, args): if 0 <= new_index < len(device.chains): device.view.selected_chain = device.chains[new_index] - def adjust_best_of_bank_param(self, device, track, xclip, ident, args): - # type: (Device, None, None, None, Text) -> None + def adjust_bob_param(self, device, track, xclip, args): + # type: (Device, None, None, Text) -> None '''Adjust device best-of-bank parameter.''' param = None name_split = args.split() @@ -120,8 +132,8 @@ def adjust_best_of_bank_param(self, device, track, xclip, ident, args): if param and param.is_enabled: self._parent.do_parameter_adjustment(param, name_split[-1]) - def adjust_banked_param(self, device, track, xclip, ident, args): - # type: (Device, None, None, None, Text) -> None + def adjust_banked_param(self, device, track, xclip, args): + # type: (Device, None, None, Text) -> None '''Adjust device banked parameter.''' param = None name_split = args.split() @@ -130,16 +142,16 @@ def adjust_banked_param(self, device, track, xclip, ident, args): if param and param.is_enabled: self._parent.do_parameter_adjustment(param, name_split[-1]) - def adjust_chain_selector(self, device, track, xclip, ident, args): - # type: (Device, None, None, None, Text) -> None + def adjust_chain_selector(self, device, track, xclip, args): + # type: (Device, None, None, Text) -> None '''Adjust device chain selector parameter.''' 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]) - def randomize_params(self, device, track, xclip, ident, args): - # type: (Device, None, None, None, None) -> None + 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( @@ -150,8 +162,8 @@ def randomize_params(self, device, track, xclip, ident, args): 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 - def reset_params(self, device, track, xclip, ident, args): - # type: (Device, None, None, None, None) -> None + 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( @@ -162,22 +174,12 @@ def reset_params(self, device, track, xclip, ident, args): if p and p.is_enabled and not p.is_quantized and p.name != 'Chain Selector': p.value = p.default_value - def select_device(self, device, track, xclip, ident, 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 - self.application().view.show_view('Detail') - self.application().view.show_view('Detail/DeviceChain') - self.song().view.select_device(device) - def set_device_on_off(self, device, value=None): # type: (Device, Optional[Text]) -> None '''Toggles or turns device on/off.''' for param in device.parameters: if str(param.name).startswith('Device On') and param.is_enabled: - param.value = KEYWORDS.get(value, not param.value) + switch(param, 'value', value) # TODO: break? def get_chain_selector(self, device): @@ -219,7 +221,7 @@ def get_banked_parameter(self, device, bank_string, param_string): 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 and bank_num <= number_of_parameter_banks(device): + 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: @@ -232,91 +234,31 @@ def get_banked_parameter(self, device, bank_string, param_string): def dispatch_chain_action(self, device, args): # type: (Device, Text) -> None '''Handle actions related to device chains.''' - if self._parent._can_have_nested_devices and device.can_have_chains and device.chains: - arg_list = args.split() - try: - chain = device.chains[int(arg_list[0].replace('CHAIN', '')) - 1] - except: - chain = None - if chain != None: - chain = device.view.selected_chain - if chain: - if len(arg_list) > 1 and arg_list[1] == 'MUTE': - try: - chain.mute = KEYWORDS[arg_list[2]] - except (IndexError, KeyError): - chain.mute = not chain.mute - elif len(arg_list) > 1 and arg_list[1] == 'SOLO': - try: - chain.solo = KEYWORDS[arg_list[2]] - except (IndexError, KeyError): - chain.solo = not chain.solo - elif len(arg_list) > 2 and not device.class_name.startswith('Midi'): - if arg_list[1] == 'VOL': - self._parent.do_parameter_adjustment(chain.mixer_device.volume, - arg_list[2].strip()) - elif arg_list[1] == 'PAN': - self._parent.do_parameter_adjustment(chain.mixer_device.panning, - arg_list[2].strip()) -# endregion - -# region LOOPER ACTIONS - def dispatch_looper_actions(self, cmd): - # type: (_DispatchCommand) -> None - from .consts import LOOPER_ACTIONS + 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.') - assert cmd.action_name == 'LOOPER' - if cmd.args and cmd.args.split()[0] in LOOPER_ACTIONS: - action = LOOPER_ACTIONS[cmd.args.split()[0]] - else: - action = LOOPER_ACTIONS[cmd.action_name] - for scmd in cmd: - self.get_looper(scmd.track) - action(self, scmd.args) + arg_list = [x.strip() for x in args.upper().split()] + try: + chain_num, action, value = arg_list[0:3] - def get_looper(self, track): - # type: (Track) -> None - '''Get first looper device on track and its params.''' - self._looper_data = dict() - for d in track.devices: - if d.class_name == 'Looper': - self._looper_data['Looper'] = d - for p in d.parameters: - if p.name in ('Device On', 'Reverse', 'State'): - self._looper_data[p.name] = p - break - elif (not self._looper_data and - self._parent._can_have_nested_devices and - d.can_have_chains and d.chains): - for c in d.chains: - self.get_looper(c) + chain = device.chains[int(chain_num.replace('CHAIN', '')) - 1] - def set_looper_on_off(self, value=None): - # type: (Optional[Text]) -> None - '''Toggles or turns looper on/off.''' - if (self._looper_data and - self._looper_data['Looper'] and - self._looper_data['Device On'].is_enabled): - self._looper_data['Device On'].value = KEYWORDS.get( - value, not self._looper_data['Device On'].value) + if action not in {'MUTE', 'SOLO', 'VOL', 'PAN'}: + raise AttributeError('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)) + except Exception as e: + log.error("Failed to parse chain action args '%s': %r", arg_list, e) + return - def set_looper_rev(self, value=None): - # type: (Optional[Text]) -> None - '''Toggles or turns looper reverse on/off.''' - if (self._looper_data and - self._looper_data['Looper'] and - self._looper_data['Reverse'].is_enabled): - self._looper_data['Reverse'].value = KEYWORDS.get( - value, not self._looper_data['Reverse'].value) + 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(param, value) - def set_looper_state(self, value=None): - # type: (Optional[Text]) -> None - '''Sets looper state.''' - if (self._looper_data and - self._looper_data['Looper'] and - self._looper_data['State'].is_enabled): - try: - self._looper_data['State'].value = LOOPER_STATES[value] - except KeyError: - log.error("'%s' is not a looper state", value) # endregion diff --git a/src/clyphx/actions/device_looper.py b/src/clyphx/actions/device_looper.py new file mode 100644 index 0000000..85e077d --- /dev/null +++ b/src/clyphx/actions/device_looper.py @@ -0,0 +1,64 @@ +from __future__ import absolute_import, unicode_literals +from builtins import object + +from ..consts import LOOPER_STATES, switch + + +# TODO: turn into a component? +class LooperMixin(object): + + def dispatch_looper_actions(self, cmd): + # type: (_DispatchCommand) -> None + from .consts import LOOPER_ACTIONS + + assert cmd.action_name == 'LOOPER' + if cmd.args and cmd.args.split()[0] in LOOPER_ACTIONS: + action = LOOPER_ACTIONS[cmd.args.split()[0]] + else: + action = LOOPER_ACTIONS[cmd.action_name] + for scmd in cmd: + self.get_looper(scmd.track) + action(self, scmd.args) + + def get_looper(self, track): + # type: (Track) -> None + '''Get first looper device on track and its params.''' + self._looper_data = dict() + for d in track.devices: + if d.class_name == 'Looper': + self._looper_data['Looper'] = d + for p in d.parameters: + if p.name in ('Device On', 'Reverse', 'State'): + self._looper_data[p.name] = p + break + elif (not self._looper_data and + self._parent._can_have_nested_devices and + d.can_have_chains and d.chains): + for c in d.chains: + self.get_looper(c) + + def get_param(self, name): + if not (self._looper_data and self._looper_data['Looper']): + raise AttributeError('No looper data') + param = self._looper_data[name] + if not param.is_enabled: + raise AttributeError("'{}' is not enabled".format(name)) + return param + + def set_looper_on_off(self, value=None): + # type: (Optional[Text]) -> None + '''Toggles or turns looper on/off.''' + switch(self.get_param('Device On'), 'value', value) + + def set_looper_rev(self, value=None): + # type: (Optional[Text]) -> None + '''Toggles or turns looper reverse on/off.''' + switch(self.get_param('Reverse'), 'value', value) + + def set_looper_state(self, value=None): + # type: (Optional[Text]) -> None + '''Sets looper state.''' + try: + self.get_param('State').value = LOOPER_STATES[value.upper()] + except KeyError: + raise ValueError("'{}' is not a looper state".format(value)) diff --git a/src/clyphx/actions/dr.py b/src/clyphx/actions/dr.py index cadc0cb..4d71899 100644 --- a/src/clyphx/actions/dr.py +++ b/src/clyphx/actions/dr.py @@ -24,7 +24,7 @@ from ..core.live import Track, Clip, Device from ..core.xcomponent import XComponent -from ..consts import KEYWORDS +from ..consts import switch MAX_SCROLL_POS = 28 @@ -43,7 +43,7 @@ def dispatch_dr_action(self, track, xclip, ident, args): # type: (Track, Clip, Text, Text) -> None from .consts import DR_ACTIONS - arg_action = DR_ACTIONS.get(args.split()[0]) + arg_action = DR_ACTIONS.get(args.split()[0].upper()) if arg_action: action = partial(arg_action, self) # type: Callable elif 'PAD' in args: @@ -98,40 +98,42 @@ def dispatch_pad_action(self, dr, track, xclip, ident, _args): if len(args) > 1: pads = self._get_pads_to_operate_on(dr, args[0].replace('PAD', '').strip()) if pads: - action = args[1] + action = args[1].upper() action_arg = args[2] if len(args) > 2 else None - if args[1] in PAD_ACTIONS: - PAD_ACTIONS[args[1]](self, pads, action_arg) - elif args[1] == 'SEL': + if action in PAD_ACTIONS: + PAD_ACTIONS[action](self, pads, action_arg) + elif action == 'SEL': dr.view.selected_drum_pad = pads[-1] - elif 'SEND' in args[1] and action_arg and len(args) > 3: + elif 'SEND' in action and action_arg and len(args) > 3: self._adjust_pad_send(pads, args[3], action_arg) def _mute_pads(self, pads, action_arg): # type: (Iterable[Any], Optional[Text]) -> None '''Toggles or turns on/off pad mute.''' for pad in pads: - pad.mute = KEYWORDS.get(action_arg, not pad.mute) + switch(pad, 'mute', action_arg) def _solo_pads(self, pads, action_arg): # type: (Iterable[Any], Optional[Text]) -> None '''Toggles or turns on/off pad solo.''' for pad in pads: - pad.solo = KEYWORDS.get(action_arg, not pad.solo) + switch(pad, 'solo', action_arg) def _adjust_pad_volume(self, pads, action_arg): # type: (Sequence[Any], Text) -> None '''Adjust/set pad volume.''' for pad in pads: if pad.chains: - self._parent.do_parameter_adjustment(pad.chains[0].mixer_device.volume, action_arg) + param = pad.chains[0].mixer_device.volume + self._parent.do_parameter_adjustment(param, action_arg) def _adjust_pad_pan(self, pads, action_arg): # type: (Sequence[Any], Text) -> None '''Adjust/set pad pan.''' for pad in pads: if pad.chains: - self._parent.do_parameter_adjustment(pad.chains[0].mixer_device.panning, action_arg) + param = pad.chains[0].mixer_device.panning + self._parent.do_parameter_adjustment(param, action_arg) def _adjust_pad_send(self, pads, action_arg, send): # type: (Sequence[Any], Text, Text) -> None diff --git a/src/clyphx/actions/global_.py b/src/clyphx/actions/global_.py index ee950cb..8a6551d 100644 --- a/src/clyphx/actions/global_.py +++ b/src/clyphx/actions/global_.py @@ -31,7 +31,7 @@ from ..core.xcomponent import XComponent from ..core.live import Application, Clip, DeviceType, get_random_int -from ..consts import KEYWORDS +from ..consts import KEYWORDS, switch from ..consts import (AUDIO_DEVS, MIDI_DEVS, INS_DEVS, GQ_STATES, REPEAT_STATES, RQ_STATES, MIDI_STATUS) @@ -349,7 +349,7 @@ def load_m4l(self, track, xclip, ident, args): def set_session_record(self, track, xclip, ident, value=None): # type: (None, None, None, Optional[Text]) -> None '''Toggles or turns on/off session record.''' - self.song().session_record = KEYWORDS.get(value, not self.song().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 @@ -386,9 +386,7 @@ def _track_has_empty_slot(self, track, start): def set_session_automation_record(self, track, xclip, ident, value=None): # type: (None, None, None, Optional[Text]) -> None '''Toggles or turns on/off session automation record.''' - self.song().session_automation_record = KEYWORDS.get( - value, not self.song().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 @@ -407,27 +405,27 @@ def set_back_to_arrange(self, track, xclip, ident, value=None): def set_overdub(self, track, xclip, ident, value=None): # type: (None, None, None, Optional[Text]) -> None '''Toggles or turns on/off overdub.''' - self.song().overdub = KEYWORDS.get(value, not self.song().overdub) + switch(self.song(), 'overdub', value) def set_metronome(self, track, xclip, ident, value=None): # type: (None, None, None, Optional[Text]) -> None '''Toggles or turns on/off metronome.''' - self.song().metronome = KEYWORDS.get(value, not self.song().metronome) + switch(self.song(), 'metronome', value) def set_record(self, track, xclip, ident, value=None): # type: (None, None, None, Optional[Text]) -> None '''Toggles or turns on/off record.''' - self.song().record_mode = KEYWORDS.get(value, not self.song().record_mode) + switch(self.song(), 'record_mode', value) def set_punch_in(self, track, xclip, ident, value=None): # type: (None, None, None, Optional[Text]) -> None '''Toggles or turns on/off punch in.''' - self.song().punch_in = KEYWORDS.get(value, not self.song().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 '''Toggles or turns on/off punch out.''' - self.song().punch_out = KEYWORDS.get(value, not self.song().punch_out) + switch(self.song(), 'punch_out', value) def restart_transport(self, track, xclip, ident, value=None): # type: (None, None, None, None) -> None @@ -824,7 +822,7 @@ def set_fold_all(self, track, xclip, ident, value): if t.is_foldable: if state_to_set is None: state_to_set = not t.fold_state - t.fold_state = KEYWORDS.get(value, state_to_set) + switch(t, 'fold_state', value, state_to_set) def set_locator(self, track, xclip, ident, args): # type: (None, None, None, None) -> None @@ -863,7 +861,7 @@ def do_loop_action(self, track, xclip, ident, args): # type: (None, None, None, Text) -> None '''Handle arrange loop action.''' args = args.strip() - if not args or args in KEYWORDS: + if not args or args.upper() in KEYWORDS: self.set_loop_on_off(args) else: new_start = self.song().loop_start @@ -892,7 +890,7 @@ def do_loop_action(self, track, xclip, ident, args): def set_loop_on_off(self, value=None): # type: (Optional[Text]) -> None '''Toggles or turns on/off arrange loop.''' - self.song().loop = KEYWORDS.get(value, not self.song().loop) + switch(self.song(), 'loop', value) def move_loop_by_factor(self, args): # type: (Text) -> None @@ -914,7 +912,7 @@ def set_new_loop_position(self, new_start, new_length): are within range. ''' # TODO: maybe (new_start + new_length < self.song().song_length) ? - if new_start >= 0 and new_length >= 0 and new_length <= self.song().song_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 diff --git a/src/clyphx/actions/push.py b/src/clyphx/actions/push.py index 877a4c4..1a5d500 100644 --- a/src/clyphx/actions/push.py +++ b/src/clyphx/actions/push.py @@ -20,7 +20,7 @@ from ..core.xcomponent import ControlSurfaceComponent from ..core.live import Clip -from ..consts import KEYWORDS, NOTE_NAMES +from ..consts import switch, NOTE_NAMES if TYPE_CHECKING: from typing import Any, Sequence, Text, List, Dict, Optional @@ -213,19 +213,11 @@ def _handle_scale_action(self, args, xclip, ident): def _handle_in_key(self, arg_array): # type: (Sequence[Text]) -> None - try: - self._ins_component._note_layout.is_in_key = KEYWORDS[arg_array[1]] - except (IndexError, KeyError): - self._ins_component._note_layout.is_in_key =\ - not self._ins_component._note_layout.is_in_key + switch(self._ins_component._note_layout, 'is_in_key', arg_array[1]) def _handle_fixed(self, arg_array): # type: (Sequence[Text]) -> None - try: - self._ins_component._note_layout.is_fixed = KEYWORDS[arg_array[1]] - except (IndexError, KeyError): - self._ins_component._note_layout.is_fixed =\ - not self._ins_component._note_layout.is_fixed + switch(self._ins_component._note_layout, 'is_fixed', arg_array[1]) def _handle_root_note(self, arg_array): # type: (Sequence[Text]) -> None diff --git a/src/clyphx/actions/track.py b/src/clyphx/actions/track.py index b4cff69..e271fa3 100644 --- a/src/clyphx/actions/track.py +++ b/src/clyphx/actions/track.py @@ -17,17 +17,20 @@ from __future__ import absolute_import, unicode_literals from builtins import range from typing import TYPE_CHECKING +import logging if TYPE_CHECKING: from ..core.live import Track from typing import Any, Optional, Text, List from ..core.legacy import _DispatchCommand -from ..consts import KEYWORDS +from ..consts import switch from ..consts import GQ_STATES, MON_STATES, XFADE_STATES from ..core.xcomponent import XComponent from ..core.live import Clip, get_random_int +log = logging.getLogger(__name__) + class XTrackActions(XComponent): '''Track-related actions. @@ -39,10 +42,11 @@ def dispatch_actions(self, cmd): from .consts import TRACK_ACTIONS action = TRACK_ACTIONS[cmd.action_name] - for scmd in cmd: - action(self, scmd.track, scmd.xclip, scmd.ident, scmd.args) - def duplicate_track(self, track, xclip, ident, args): + for i, scmd in enumerate(cmd): + action(self, scmd.track, scmd.xclip, scmd.args) + + def duplicate_track(self, track, xclip, args): # type: (Track, None, None, None) -> None '''Duplicates the given track (only regular tracks can be duplicated). @@ -50,7 +54,7 @@ def duplicate_track(self, track, xclip, ident, args): if track in self.song().tracks: self.song().duplicate_track(list(self.song().tracks).index(track)) - def delete_track(self, track, xclip, ident, args): + def delete_track(self, track, xclip, args): # type: (Track, None, None, None) -> None '''Deletes the given track as long as it's not the last track in the set (only regular tracks can be deleted). @@ -58,8 +62,8 @@ def delete_track(self, track, xclip, ident, args): if track in self.song().tracks: self.song().delete_track(list(self.song().tracks).index(track)) - def delete_device(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def delete_device(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Delete the device on the track associated with the given index. Only top level devices can be deleted. ''' @@ -70,8 +74,8 @@ def delete_device(self, track, xclip, ident, args): except: pass - def create_clip(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def create_clip(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Creates a clip in the given slot index (or sel if specified) at the given length (in bars). If no args, creates a 1 bar clip in the selected slot. @@ -98,16 +102,16 @@ def create_clip(self, track, xclip, ident, args): if not track.clip_slots[slot].has_clip: track.clip_slots[slot].create_clip(length) - def set_name(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def set_name(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Set track's name.''' if track in self.song().tracks or track in self.song().return_tracks: args = args.strip() if args: track.name = args - def rename_all_clips(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def rename_all_clips(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Renames all clips on the track based on the track's name or the name specified in args. ''' @@ -117,33 +121,33 @@ def rename_all_clips(self, track, xclip, ident, args): if slot.has_clip: slot.clip.name = '{} {}'.format(name, i + 1) - def set_mute(self, track, xclip, ident, value=None): + def set_mute(self, track, xclip, value=None): # type: (Track, None, None, Optional[Text]) -> None '''Toggles or turns on/off track mute. ''' if track in self.song().tracks or track in self.song().return_tracks: - track.mute = KEYWORDS.get(value, not track.mute) + switch(track, 'mute', value) - def set_solo(self, track, xclip, ident, value=None): + def set_solo(self, track, xclip, value=None): # type: (Track, None, None, Optional[Text]) -> None '''Toggles or turns on/off track solo.''' if track in self.song().tracks or track in self.song().return_tracks: - track.solo = KEYWORDS.get(value, not track.solo) + switch(track, 'solo', value) - def set_arm(self, track, xclip, ident, value=None): + def set_arm(self, track, xclip, value=None): # type: (Track, None, None, Optional[Text]) -> None '''Toggles or turns on/off track arm.''' if track in self.song().tracks and track.can_be_armed: - track.arm = KEYWORDS.get(value, not track.arm) + switch(track, 'arm', value) - def set_fold(self, track, xclip, ident, value=None): + def set_fold(self, track, xclip, value=None): # type: (Track, None, None, Optional[Text]) -> None '''Toggles or turns on/off track fold.''' if track.is_foldable: - track.fold_state = KEYWORDS.get(value, not track.fold_state) + switch(track, 'fold_state', value) - def set_monitor(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def set_monitor(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Toggles or sets monitor state.''' if track in self.song().tracks and not track.is_foldable: try: @@ -154,8 +158,8 @@ def set_monitor(self, track, xclip, ident, args): else: track.current_monitoring_state += 1 - def set_xfade(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def set_xfade(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Toggles or sets crossfader assignment.''' if track != self.song().master_track: try: @@ -166,8 +170,8 @@ def set_xfade(self, track, xclip, ident, args): else: track.mixer_device.crossfade_assign += 1 - def set_selection(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def set_selection(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Sets track/slot selection.''' self.song().view.selected_track = track if track in self.song().tracks: @@ -176,12 +180,11 @@ def set_selection(self, track, xclip, ident, args): self.song().view.selected_scene = list(self.song().scenes)[int(args) - 1] except: pass - else: - if track.playing_slot_index >= 0: - self.song().view.selected_scene = list(self.song().scenes)[track.playing_slot_index] + elif track.playing_slot_index >= 0: + self.song().view.selected_scene = list(self.song().scenes)[track.playing_slot_index] - def set_jump(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def set_jump(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Jumps playing clip on track forward/backward.''' if track in self.song().tracks: try: @@ -189,15 +192,15 @@ def set_jump(self, track, xclip, ident, args): except: pass - def set_stop(self, track, xclip, ident, value=None): - # type: (Track, None, None, Optional[Text]) -> None + def set_stop(self, track, xclip, value=None): + # type: (Track, None, Optional[Text]) -> None '''Stops all clips on track w/no quantization option for Live 9. ''' if track in self.song().tracks: track.stop_all_clips((value or '').strip() != 'NQ') - def set_play(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def set_play(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Plays clips normally. Allow empty slots unless using keywords. ''' allow_empty_slots = args != '<' and args != '>' @@ -205,8 +208,8 @@ def set_play(self, track, xclip, ident, args): if slot_to_play != -1: track.clip_slots[slot_to_play].fire() - def set_play_w_legato(self, track, xclip, ident, args): - # type: (Track, Any, None, Text) -> None + def set_play_w_legato(self, track, xclip, args): + # type: (Track, Any, Text) -> None '''Plays the clip with legato using the current global quantization. This will not launch empty slots. ''' @@ -217,15 +220,15 @@ def set_play_w_legato(self, track, xclip, ident, args): launch_quantization=self.song().clip_trigger_quantization, ) - def set_play_w_force_qntz(self, track, xclip, ident, args): - # type: (Track, Any, None, Text) -> None + def set_play_w_force_qntz(self, track, xclip, args): + # type: (Track, Any, Text) -> None '''Plays the clip with a specific quantization regardless of launch/global quantization. This will not launch empty slots. ''' self._handle_force_qntz_play(track, xclip, args, False) - def set_play_w_force_qntz_and_legato(self, track, xclip, ident, args): - # type: (Track, Any, None, Text) -> None + def set_play_w_force_qntz_and_legato(self, track, xclip, args): + # type: (Track, Any, Text) -> None '''Combination of play_legato and play_w_force_qntz.''' self._handle_force_qntz_play(track, xclip, args, True) @@ -254,107 +257,109 @@ def _get_slot_index_to_play(self, track, xclip, args, allow_empty_slots=False): # type: (Track, Any, Text, bool) -> int '''Returns the slot index to play based on keywords in the given args. ''' + if track not in self.song().tracks: + return -1 + slot_to_play = -1 - if track in self.song().tracks: - 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 - elif args == 'SEL': - slot_to_play = select_slot - # TODO: repeated, check refactoring - # don't allow randomization unless more than 1 scene - elif '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] - slot_to_play = get_random_int(0, rnd_range[1] - rnd_range[0]) + rnd_range[0] - if slot_to_play == play_slot: - while slot_to_play == play_slot: - slot_to_play = 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: - if track.is_foldable: - return -1 - factor = self._parent.get_adjustment_factor(args) - if factor < len(self.song().scenes): - # only launch slots that contain clips - if abs(factor) == 1: - for _ in range(len(self.song().scenes)): - play_slot += factor - if play_slot >= len(self.song().scenes): - play_slot = 0 - if track.clip_slots[play_slot].has_clip and track.clip_slots[play_slot].clip != xclip: - break - else: + 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 + elif args == 'SEL': + slot_to_play = select_slot + # TODO: repeated, check refactoring + # don't allow randomization unless more than 1 scene + elif '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] + slot_to_play = get_random_int(0, rnd_range[1] - rnd_range[0]) + rnd_range[0] + if slot_to_play == play_slot: + while slot_to_play == play_slot: + slot_to_play = 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: + if track.is_foldable: + return -1 + factor = self._parent.get_adjustment_factor(args) + if factor < len(self.song().scenes): + # only launch slots that contain clips + if abs(factor) == 1: + for _ in range(len(self.song().scenes)): play_slot += factor if play_slot >= len(self.song().scenes): - play_slot -= len(self.song().scenes) - elif play_slot < 0 and abs(play_slot) >= len(self.song().scenes): - play_slot = -(abs(play_slot) - len(self.song().scenes)) - slot_to_play = play_slot - elif args.startswith('"') and args.endswith('"'): - clip_name = args.strip('"') - for i in range(len(track.clip_slots)): - slot = track.clip_slots[i] - if slot.has_clip and slot.clip.name.upper() == clip_name: - slot_to_play = i - break - else: - try: - if 0 <= int(args) < len(self.song().scenes) + 1: - slot_to_play = int(args) - 1 - except: - pass + play_slot = 0 + if track.clip_slots[play_slot].has_clip and track.clip_slots[play_slot].clip != xclip: + break + else: + play_slot += factor + if play_slot >= len(self.song().scenes): + play_slot -= len(self.song().scenes) + elif play_slot < 0 and abs(play_slot) >= len(self.song().scenes): + play_slot = -(abs(play_slot) - len(self.song().scenes)) + slot_to_play = play_slot + elif args.startswith('"') and args.endswith('"'): + clip_name = args.strip('"') + for i in range(len(track.clip_slots)): + slot = track.clip_slots[i] + if slot.has_clip and slot.clip.name.upper() == clip_name: + slot_to_play = i + break else: - return -1 - if (not track.clip_slots[slot_to_play].has_clip and allow_empty_slots) or ( - track.clip_slots[slot_to_play].has_clip and - track.clip_slots[slot_to_play].clip != xclip - ): + try: + if 0 <= int(args) < len(self.song().scenes) + 1: + slot_to_play = int(args) - 1 + except: + pass + + if ((not track.clip_slots[slot_to_play].has_clip and allow_empty_slots) + or (track.clip_slots[slot_to_play].has_clip + and track.clip_slots[slot_to_play].clip != xclip)): return slot_to_play else: return -1 - def adjust_preview_volume(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + 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.song().master_track.mixer_device.cue_volume, args.strip()) + self._parent.do_parameter_adjustment( + self.song().master_track.mixer_device.cue_volume, args.strip()) - def adjust_crossfader(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + 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.song().master_track.mixer_device.crossfader, args.strip()) + self._parent.do_parameter_adjustment( + self.song().master_track.mixer_device.crossfader, args.strip()) - def adjust_volume(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + 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()) - def adjust_pan(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + 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()) - def adjust_sends(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def adjust_sends(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Adjust/set track sends.''' largs = args.split() if len(args) > 1: @@ -373,52 +378,53 @@ def get_send_parameter(self, track, send_string): pass return param - def adjust_input_routing(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None +# region ROUTING + def adjust_input_routing(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Adjust track input routing.''' if track in self.song().tracks and not track.is_foldable: routings = list(track.input_routings) - if track.current_input_routing in routings: + try: current_routing = routings.index(track.current_input_routing) - else: + except ValueError: current_routing = 0 track.current_input_routing = self.handle_track_routing(args, routings, current_routing) - def adjust_input_sub_routing(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def adjust_input_sub_routing(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Adjust track input sub-routing.''' if track in self.song().tracks and not track.is_foldable: routings = list(track.input_sub_routings) - if track.current_input_sub_routing in routings: + try: current_routing = routings.index(track.current_input_sub_routing) - else: + except ValueError: current_routing = 0 track.current_input_sub_routing = self.handle_track_routing(args, routings, current_routing) - def adjust_output_routing(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def adjust_output_routing(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Adjust track output routing.''' if track != self.song().master_track: routings = list(track.output_routings) - if track.current_output_routing in routings: + try: current_routing = routings.index(track.current_output_routing) - else: + except ValueError: current_routing = 0 track.current_output_routing = self.handle_track_routing(args, routings, current_routing) - def adjust_output_sub_routing(self, track, xclip, ident, args): - # type: (Track, None, None, Text) -> None + def adjust_output_sub_routing(self, track, xclip, args): + # type: (Track, None, Text) -> None '''Adjust track output sub-routing.''' if track != self.song().master_track: routings = list(track.output_sub_routings) - if track.current_output_sub_routing in routings: + try: current_routing = routings.index(track.current_output_sub_routing) - else: + except ValueError: current_routing = 0 track.current_output_sub_routing = self.handle_track_routing(args, routings, current_routing) def handle_track_routing(self, args, routings, current_routing): - # type: (Text, List[Any], Any) -> Any + # type: (Text, List[Text], int) -> Text '''Handle track routing adjustment.''' new_routing = routings[current_routing] args = args.strip() @@ -432,3 +438,4 @@ def handle_track_routing(self, args, routings, current_routing): new_routing = i break return new_routing +# end region diff --git a/src/clyphx/clyphx.py b/src/clyphx/clyphx.py index 7222a5b..4728351 100644 --- a/src/clyphx/clyphx.py +++ b/src/clyphx/clyphx.py @@ -21,13 +21,12 @@ import logging import os -from _Framework.ControlSurface import ControlSurface -from .core.legacy import _DispatchCommand, _SingleDispatch, ActionList -from .core.utils import get_base_path, repr_tracklist, set_user_profile +from _Framework.ControlSurface import OptimizedControlSurface +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.parsing import get_xclip_action_list +# from .core.parse import SpecParser from .consts import LIVE_VERSION, SCRIPT_INFO -from .xtriggers import XTrackComponent, XControlComponent, XCueComponent from .extra_prefs import ExtraPrefs from .user_config import get_user_settings from .user_actions import XUserActions @@ -36,6 +35,13 @@ from .m4l_browser import XM4LBrowserInterface from .push_apc_combiner import PushApcCombiner from .push_mocks import MockHandshakeTask, MockHandshake +from .triggers import ( + XTrackComponent, + XControlComponent, + XCueComponent, + ActionList, + get_xclip_action_list, +) from .actions import ( XGlobalActions, GLOBAL_ACTIONS, XTrackActions, TRACK_ACTIONS, @@ -47,19 +53,17 @@ ) if TYPE_CHECKING: - from typing import ( - Any, Text, Union, Optional, Dict, - Iterable, Sequence, List, Tuple, - ) - from .core.live import ( - Clip, Device, DeviceParameter, Track, MidiRemoteScript, - ) + from typing import (Any, Text, Union, Optional, Dict, + Iterable, Sequence, List, Tuple) + from .core.live import (Clip, Device, DeviceParameter, + Track, MidiRemoteScript) + from .triggers import XTrigger log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) -class ClyphX(ControlSurface): +class ClyphX(OptimizedControlSurface): '''ClyphX Main. ''' __module__ = __name__ @@ -74,6 +78,7 @@ def __init__(self, c_instance): self._PushApcCombiner = None self._process_xclips_if_track_muted = True self._user_settings = get_user_settings() + # self.parse = SpecParser() with self.component_guard(): self.macrobat = Macrobat(self) self._extra_prefs = ExtraPrefs(self, self._user_settings.prefs) @@ -148,13 +153,18 @@ def start_debugging(self): log.info('------- Debugging Started -------') def handle_dispatch_command(self, cmd): + try: + self._handle_dispatch_command(cmd) + except Exception as e: + log.exception('Failed to dispatch command: %r', cmd) + + def _handle_dispatch_command(self, cmd): # type: (_DispatchCommand) -> None '''Command handler. Main dispatch for calling appropriate class of actions, passes all necessary arguments to class method. ''' - log.debug('handle action dispatch: %r', cmd) name = cmd.action_name if not cmd.tracks: @@ -188,7 +198,6 @@ def handle_dispatch_command(self, cmd): else: log.error('Not found dispatcher for %r', cmd) return - log.debug('action dispatch triggered: %r', cmd) def dispatch_user_actions(self, cmd): # type: (_DispatchCommand) -> None @@ -198,6 +207,7 @@ def dispatch_user_actions(self, cmd): action(t, cmd.args) def handle_external_trigger(self, xtrigger): + # type: (XTrigger) -> None '''This replaces the below method for compatibility with scripts that also work with ClyphX Pro. ''' @@ -221,30 +231,29 @@ def handle_m4l_trigger(self, name): ActionList('[] {}'.format(name))) def handle_action_list_trigger(self, track, xtrigger): - # type: (Track, Any) -> None + # 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) - # XXX: True both if obj represents a lost weakref or is None. - # Live API objects must not be checked using "is None", - # since this would treat lost weakrefs as valid. if xtrigger == None: # TODO: use case? return name = xtrigger.name.strip().upper() - log.info("handle action list trigger name '%s'", name) if name and name[0] == '[' and ']' in name: + # 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) + # 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() @@ -288,20 +297,23 @@ def handle_action_list_trigger(self, track, xtrigger): 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 = _SingleDispatch(action['track'], xtrigger, ident, action['action'], action['args']) + command = _DispatchCommand(action['track'], + xtrigger, + ident, + action['action'], + action['args']) self.handle_dispatch_command(command) - log.debug('handle_action_list_trigger triggered: %r', command) def format_action_name(self, origin_track, origin_name): # type: (Any, Text) -> Optional[Dict[Text, Any]] '''Replaces vars (if any) then splits up track, action name and arguments (if any) and returns dict. ''' - log.info('format action name') - result_name = self.replace_user_variables(origin_name) + result_name = self._user_settings.vars.sub(origin_name) if '=' in result_name: - self.handle_user_variable_assignment(result_name) + self._user_settings.vars.add(result_name) return None result_track = [origin_track] if len(result_name) >= 4 and ( @@ -319,55 +331,6 @@ def format_action_name(self, origin_track, origin_name): repr_tracklist(result_track), result_name.strip(), args.strip()) return dict(track=result_track, action=result_name.strip(), args=args.strip()) - def replace_user_variables(self, string): - # type: (Text) -> Text - '''Replace any user variables in the given string with the value - the variable represents. - ''' - log.info('replace user variables') - while '%' in string: - var = string[string.index('%')+1:] - if '%' in var: - var = var[0:var.index('%')] - string = string.replace( - '%{}%'.format(var), self._user_variables.get(var, '0'), 1 - ) - else: - string = string.replace('%', '', 1) - if '$' in string: - # for compat with old-style variables - for s in string.split(): - if '$' in s and not '=' in s: - var = s.replace('$', '') - string = string.replace( - '${}'.format(var), self._user_variables.get(var, '0'), 1 - ) - log.debug('replace_user_variables returning %s', string) - return string - - def handle_user_variable_assignment(self, string_with_assign): - # type: (Text) -> None - '''Handle assigning new value to variable with either assignment - or expression enclosed in parens. - ''' - log.debug('handle user variable assignment: %s', string_with_assign) - # for compat with old-style variables - string_with_assign = string_with_assign.replace('$', '') - try: - var0, var1 = [x.strip() for x in string_with_assign.split('=')][0:2] - if not any(x in var1 for x in (';', '%', '=')): - if '(' in var1 and ')' in var1: - try: - self._user_variables[var0] = str(eval(var1)) - except Exception as e: - raise Exception(e) - #pass - else: - self._user_variables[var0] = var1 - log.debug('handle_user_variable_assignment, %s=%s', var0, var1) - except ValueError: - log.error('in handle_user_variable_assignment(string_with_assign=%s)', string_with_assign) - def handle_loop_seq_action_list(self, xclip, count): # type: (Clip, int) -> None '''Handles sequenced action lists, triggered by xclip looping. @@ -386,7 +349,6 @@ def handle_loop_seq_action_list(self, xclip, count): action['action'], action['args']) self.handle_dispatch_command(command) - log.debug('handle_loop_seq_action_list triggered: %r', command) def handle_play_seq_action_list(self, action_list, xclip, ident): # type: (Any, Clip, Text) -> None @@ -408,7 +370,6 @@ def handle_play_seq_action_list(self, action_list, xclip, ident): action['action'], action['args']) self.handle_dispatch_command(command) - log.debug('handle_play_seq_action_list triggered: %r', command) def do_parameter_adjustment(self, param, value): # type: (DeviceParameter, Text) -> None @@ -460,7 +421,7 @@ def do_parameter_adjustment(self, param, value): param.name, new_value) def get_adjustment_factor(self, string, as_float=False): - # type: (Text, bool) -> Optional[int] + # type: (Text, bool) -> Union[int, float] '''Get factor for use with < > actions.''' factor = 1 @@ -472,17 +433,15 @@ def get_adjustment_factor(self, string, as_float=False): if string.startswith('<'): factor = -(factor) - log.debug('get_adjustment_factor returning factor=%s', 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.''' - log.debug('get track to operate on') result_tracks = [] result_name = origin_name if '/' in origin_name: - tracks = self.song().tracks + self.song().return_tracks + [self.song().master_track] # type: List[Track] + tracks = self.song().tracks + self.song().return_tracks + (self.song().master_track,) # type: Tuple[Track] sel_track_index = tracks.index(self.song().view.selected_track) if origin_name.index('/') > 0: track_spec = origin_name.split('/')[0].strip() @@ -535,7 +494,6 @@ def get_track_index_by_name(self, name, tracks): '''Gets the index(es) associated with the track name(s) specified in name. ''' - log.info('get track index by name') while '"' in name: track_name = name[name.index('"')+1:] if '"' in track_name: @@ -561,7 +519,6 @@ def get_device_to_operate_on(self, track, action_name, args): # type: (Track, Text, Text) -> Tuple[Optional[Device], Text] '''Get device to operate on and action to perform with args. ''' - log.info('get device to operate on') device = None device_args = args if 'DEV"' in action_name: @@ -638,43 +595,6 @@ def get_user_settings(self, midi_map_handle): self.control_component.get_user_controls(self._user_settings.xcontrols, midi_map_handle) - lists_to_build = {'vars': list()} - - filepath = get_base_path('UserSettings.txt') - if not self._user_settings_logged: - log.info('Attempting to read UserSettings file: %s', filepath) - - for line in open(filepath): - line = line.strip().upper() - if not line or line.startswith(('#', '"')): - continue - if line[0] != '*' and not self._user_settings_logged: - log.info(str(line)) - if not line.startswith( - ('STARTUP_', 'INCLUDE_NESTED_', 'SNAPSHOT_', - 'PROCESS_XCLIPS_', 'PUSH_EMU', 'APC_PUSH_EMU', 'CSLINKER') - ): - if '[USER CONTROLS]' in line: - list_to_build = 'controls' - elif '[USER VARIABLES]' in line: - list_to_build = 'vars' - elif '[EXTRA PREFS]' in line: - list_to_build = 'prefs' - elif '=' in line: - if list_to_build in {'vars'}: - lists_to_build['vars'].append(line) - elif 'PUSH_EMULATION' in line: - self._push_emulation = line.split('=')[1].strip() == 'ON' - if self._push_emulation: - if 'APC' in line: - with self.component_guard(): - self._PushApcCombiner = PushApcCombiner(self) - self.enable_push_emulation(self._control_surfaces()) - - for line in lists_to_build['vars']: - line = self.replace_user_variables(line) - self.handle_user_variable_assignment(line) - def enable_push_emulation(self, scripts): # type: (Iterable[Any]) -> None '''Try to disable Push's handshake to allow for emulation. diff --git a/src/clyphx/consts.py b/src/clyphx/consts.py index 15eaa85..2b5541d 100644 --- a/src/clyphx/consts.py +++ b/src/clyphx/consts.py @@ -26,12 +26,13 @@ get_application) if TYPE_CHECKING: - from typing import Any, Optional, Sequence, Text, Dict, Mapping + from typing import Any, Optional, Sequence, Text, Dict, Mapping, TypeVar from .core.live import Application + T = TypeVar('T') log = logging.getLogger(__name__) - app = get_application() # type: Application +unset = object() LIVE_VERSION = (app.get_major_version(), app.get_minor_version(), @@ -49,6 +50,16 @@ KEYWORDS = dict(ON=1, OFF=0) # type: Mapping[Optional[Text], bool] +def switch(obj, attr, value, fallback=unset): + # type: (T, Text, Text, Any) -> None + '''Turns object attribute on/off or toggles (if there is no fallback). + ''' + try: + v = KEYWORDS[value.upper().strip()] + except (AttributeError, KeyError): + v = (not getattr(obj, attr)) if fallback is unset else fallback + setattr(obj, attr, v) + ONOFF = dict(ON=True, OFF=False) # type: Mapping[Text, bool] NOTE_NAMES = ('C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B') diff --git a/src/clyphx/core/exceptions.py b/src/clyphx/core/exceptions.py index 8bd791d..6b8a8b6 100644 --- a/src/clyphx/core/exceptions.py +++ b/src/clyphx/core/exceptions.py @@ -1,6 +1,12 @@ class ClyphXception(Exception): + def __init__(self, msg=None, *args, **kwargs): + suoer().__init__(*args, **kwargs) + +class ParsingError(ClyphXception, ValueError): pass +class InvalidSpec(ClyphXception, ValueError): + pass -class ParsingError(ValueError, ClyphXception): +class InvalidParam(ClyphXception, ValueError): pass diff --git a/src/clyphx/core/legacy.py b/src/clyphx/core/legacy.py index 21e6800..ffc46df 100644 --- a/src/clyphx/core/legacy.py +++ b/src/clyphx/core/legacy.py @@ -1,12 +1,13 @@ # coding: utf-8 # -# Copyright 2020-2021 Nuno André Novo +# 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 object, super +import logging from .utils import repr_tracklist @@ -14,6 +15,8 @@ from typing import Union, Text, Sequence, Iterator from .live import Track, Clip +log = logging.getLogger(__name__) + class _DispatchCommand(object): '''Action dispatching command transition class. @@ -37,7 +40,7 @@ def __repr__(self): def __iter__(self): # type: () -> Iterator[_SingleDispatch] - for i, t in self.tracks: + for i, t in enumerate(self.tracks): yield _SingleDispatch(t, self.xclip, self.ident, self.action_name, self.args) def to_single(self): @@ -49,16 +52,11 @@ class _SingleDispatch(_DispatchCommand): '''Dispatch command for a single track. ''' def __init__(self, track, xclip, ident, action_name, args): + if isinstance(track, list): + # FIXME: + if len(track) == 1: + track = track[0] + else: + raise TypeError('SingleDispatch should receive an only track: %s', track) super().__init__([track], xclip, ident, action_name, args) self.track = track - - -class ActionList(object): - '''Allows X-Triggers with no name to trigger Action Lists. It can - also be used to trigger ClyphX Actions via UserActions. - ''' - __module__ = __name__ - - def __init__(self, name='none'): - # type: (Text) -> None - self.name = name diff --git a/src/clyphx/core/models.py b/src/clyphx/core/models.py index de6b7aa..ac2358b 100644 --- a/src/clyphx/core/models.py +++ b/src/clyphx/core/models.py @@ -1,6 +1,6 @@ # coding: utf-8 # -# Copyright 2020-2021 Nuno André Novo +# Copyright (c) 2020-2021 Nuno André Novo # Some rights reserved. See COPYING, COPYING.LESSER # SPDX-License-Identifier: LGPL-2.1-or-later @@ -21,10 +21,10 @@ ('args', List[Text])]) -Command = NamedTuple('Command', [('id', Text), - ('seq', Text), - ('start', List[Action]), - ('stop', List[Action])]) +Spec = NamedTuple('Spec', [('id', Text), + ('seq', Text), + ('on', List[Action]), + ('off', List[Action])]) class UserControl(object): diff --git a/src/clyphx/core/parse.py b/src/clyphx/core/parse.py index 893bdca..6579774 100644 --- a/src/clyphx/core/parse.py +++ b/src/clyphx/core/parse.py @@ -1,6 +1,6 @@ # coding: utf-8 # -# Copyright 2020-2021 Nuno André Novo +# Copyright (c) 2020-2021 Nuno André Novo # Some rights reserved. See COPYING, COPYING.LESSER # SPDX-License-Identifier: LGPL-2.1-or-later @@ -9,14 +9,14 @@ from builtins import map, object import re -from .models import Action, Command, UserControl +from .models import Action, Spec, UserControl from .exceptions import ParsingError if TYPE_CHECKING: from typing import Any, Optional, Text, Iterator, Iterable -# region TERMINAL SYMBOLS +# region TERMINAL SYMBOLS OBJECT_SYMBOLS = { 'SEL', 'LAST', @@ -31,42 +31,62 @@ '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*?)\]\s*?' - r'(\((?PR?[PL]SEQ)\))?\s*?' - r'(?P\S.*?)\s*?$', re.I) - lists = re.compile(r'(?P[^:]*?)' - r'(?:\s*?[:]\s*?(?P\S[^:]*?))?\s*?$') + #: spec structure + spec = re.compile(r''' + \[(?P\w*?)\]\s*? + (\((?PR?[PL]SEQ)\))?\s*? + (?P\S.*?)\s*?$ + ''', re.I | re.X) + + #: action lists + lists = re.compile(r''' + (?P[^:]*?) + (?:\s*?[:]\s*?(?P\S[^:]*?))?\s*?$ + ''', re.X) + + #: list actions actions = re.compile(r'^\s+|\s*;\s*|\s+$') - # action = re.compile(r'^((?P[A-Z0-9"\-,<>\s]*?)/)?' - # r'(?P[A-Z0-9]+?)' - # r'(\((?P[A-Z0-9"\-,<>.]+?)\))?' - # r'(\s+?(?P(?! ).*))?$', re.I) - action = re.compile(r'^((?P[\w<>"\-,\s]*?)/)?' - r'(?P[A-Z0-9]+?)' - r'(\((?P[A-Z0-9"\-,<>.]+?)\))?' - r'(\s+?(?P(?! ).*))?$', re.I) - # tracks = re.compile(r'^\s+|\s*,\s*|\s+$') - tracks = re.compile(r'\"(?P[A-Z0-9\-<>\s]*?)?\"|' - r'(?:^|[\s,]?(?P[A-Z0-9<>]*)|' - r'(?P[A-Z0-9\-<>]*))(?:[\s,]|$)', re.I) - - # TODO: parsing and validation - # def parse_tracks(self, tracks): - # if not tracks: - # return - # return self.tracks.split(tracks) + + #: 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 - return self.tracks.split(tracks) + + # 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): @@ -87,17 +107,21 @@ def parse_action_list(self, actions): raise ValueError("'{}' is not a valid action list".format(actions)) def __call__(self, text): - # type: (Text) -> Command - # split 'id', 'seq' (if any) and action 'lists' from the origin string - cmd = self.command.search(text).groupdict() - # split previous 'lists' into 'start' and 'stop' action lists - start, stop = self.lists.match(cmd.pop('lists')).groups() + # 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() try: - stop = list(self.parse_action_list(stop)) + off = list(self.parse_action_list(off)) except ValueError as e: - if not stop or stop == '*': - stop = stop or None + if not off or off == '*': + off = off or None else: - raise ParsingError(e) - cmd.update(start=list(self.parse_action_list(start)), stop=stop) # type: ignore - return Command(**cmd) + # raise ParsingError(e) + raise Exception(e) + + spec.update(on=list(self.parse_action_list(on)), off=off) # type: ignore + return Spec(**spec) diff --git a/src/clyphx/core/parsing.py b/src/clyphx/core/parsing.py deleted file mode 100644 index aa3a7db..0000000 --- a/src/clyphx/core/parsing.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import TYPE_CHECKING -import logging - -if TYPE_CHECKING: - from typing import Text, Iterable - from core.live import Clip, Track - -log = logging.getLogger(__name__) - - -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. - 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. - ''' - log.debug('get_xclip_action_list(xclip=%r, full_action_list=%s', xclip, full_action_list) - split_list = full_action_list.split(',') - - if xclip.is_playing: - result = split_list[0] - elif len(split_list) == 2: - if split_list[1].strip() == '*': - result = split_list[0] - else: - result = split_list[1] - else: - # FIXME: shouldn't be None - result = '' - - log.debug('get_xclip_action_list returning %s', result) - return result diff --git a/src/clyphx/core/utils.py b/src/clyphx/core/utils.py index ec29345..6a54b02 100644 --- a/src/clyphx/core/utils.py +++ b/src/clyphx/core/utils.py @@ -30,7 +30,10 @@ def repr_tracklist(tracks): if not tracks: return '[None]' else: - return '[{}]'.format(', '.join(t.name for t in tracks)) + try: + return '[{}]'.format(', '.join(t.name for t in tracks)) + except: + return '[ERROR {}]'.format(tracks) def get_base_path(*items): diff --git a/src/clyphx/cs_linker.py b/src/clyphx/cs_linker.py index c30952a..80c9f00 100644 --- a/src/clyphx/cs_linker.py +++ b/src/clyphx/cs_linker.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: from typing import Any, Iterable, Sequence, Optional, Dict, Text, List + from .core.live import MidiRemoteScript from _Framework.ControlSurface import ControlSurface from .core.xcomponent import ControlSurfaceComponent, SessionComponent @@ -53,6 +54,7 @@ def update(self): pass def read_settings(self, settings): + # type: (Dict[Text, Any]) -> None '''Read settings dict. ''' self._matched_link = settings.get('cslinker_matched_link', False) @@ -71,7 +73,7 @@ def read_settings(self, settings): self.connect_script_instances(cs) def connect_script_instances(self, instantiated_scripts): - # type: (Iterable[Any]) -> None + # type: (Iterable[MidiRemoteScript]) -> None '''Attempts to find the two specified scripts, find their SessionComponents and create slave objects for them. ''' @@ -143,6 +145,7 @@ def on_track_list_changed(self): def on_scene_list_changed(self): '''Refreshes slave objects if vertically linked.''' + # TODO: horizontal? if not self._matched_link and (not self._horizontal_link or self._multi_axis_link): self._refresh_slave_objects() diff --git a/src/clyphx/instant_doc.py b/src/clyphx/instant_doc.py index b3a1189..5749fc7 100644 --- a/src/clyphx/instant_doc.py +++ b/src/clyphx/instant_doc.py @@ -55,9 +55,7 @@ class InstantMappingMakeDoc(object): ''' def __init__(self): - log.info('InstantMappingMakeDoc initialized.') self._create_html_file() - log.info('InstantMappingMakeDoc finished.') def _get_devices_info(self): # type: () -> Dict[Text, Dict[Text, Any]] diff --git a/src/clyphx/m4l_browser.py b/src/clyphx/m4l_browser.py index b1d6b8a..b443e49 100644 --- a/src/clyphx/m4l_browser.py +++ b/src/clyphx/m4l_browser.py @@ -19,7 +19,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Text, List, Dict + from typing import Any, Text, List, Tuple, Dict from .core.live import Device from .core.xcomponent import XComponent @@ -40,10 +40,10 @@ class XM4LBrowserInterface(XComponent): def __init__(self, parent): # type: (Any) -> None super().__init__(parent) - self._selected_tag = dict() # type: ignore - self._selected_device = dict() # type: ignore - self._selected_folder = dict() # type: ignore - self._selected_item = dict() # type: ignore + self._selected_tag = dict() # type: Dict[Any, Any] + self._selected_device = dict() # type: Dict[Any, Any] + self._selected_folder = dict() # type: Dict[Any, Any] + self._selected_item = dict() # type: Dict[Any, Any] self._browser = dict() # type: Dict[Text, Any] def disconnect(self): @@ -69,6 +69,7 @@ def activate_hotswap(self): appropriate tag and device to use and returns the items for the device. ''' + # type: () -> List[Any] device = self.song().view.selected_track.view.selected_device items = [] if device: @@ -92,7 +93,8 @@ def activate_hotswap(self): self.application().view.toggle_browse() self._selected_tag = self._browser[tag_to_use] self._selected_device = self._selected_tag['devices'][device_to_use] - items = sorted(self._selected_device['folders'].keys()) + sorted(self._selected_device['items']) + items = (sorted(self._selected_device['folders'].keys()) + + sorted(self._selected_device['items'])) return items def deactivate_hotswap(self): @@ -115,6 +117,7 @@ def get_browser_tags(self): '''Returns the list of browser tags. Also, initializes browser if it hasn't already been initialized. ''' + # type: () -> Tuple[Text] if not self._browser: for tag in self.application().browser.tags: if tag.name in BROWSER_TAGS: @@ -138,7 +141,8 @@ def get_items_for_device(self, device_name): and stores the device. ''' self._selected_device = self._selected_tag['devices'][device_name] - return sorted(self._selected_device['folders'].keys()) + sorted(self._selected_device['items']) + return (sorted(self._selected_device['folders'].keys()) + + sorted(self._selected_device['items'])) def get_items_for_folder(self, folder_name): # type: (Text) -> List[Text] @@ -163,6 +167,7 @@ def _create_devices_for_tag(self, tag): is needed for M4L tag, which only contains folders, and Drums tag, which ontains devices and folders. ''' + # type: (Text) -> Dict[Text, Any] device_dict = dict() if tag.name == 'Max for Live': for child in tag.children: @@ -197,7 +202,7 @@ def _create_devices_for_tag(self, tag): return device_dict def _create_items_for_device(self, device): - # type: (Device) -> Dict[Text, Any] + # type: (Device) -> Dict[Text, Device] '''Returns a dict of loadable items for the given device or folder. ''' @@ -208,6 +213,7 @@ def _create_items_for_device(self, device): return items def _create_folders_for_device(self, device): + # type: (Device) -> Dict[Text, Dict[Text, Device]] '''Creates dict of folders for the given device.''' return dict(('{} >'.format(c.name), self._create_items_for_device(c)) for c in device.children if c.is_folder) diff --git a/src/clyphx/macrobat/consts.py b/src/clyphx/macrobat/consts.py new file mode 100644 index 0000000..b682b8a --- /dev/null +++ b/src/clyphx/macrobat/consts.py @@ -0,0 +1,39 @@ +from __future__ import absolute_import, unicode_literals +from collections import OrderedDict +from builtins import dict + +from .midi_rack import MacrobatMidiRack +from .push_rack import MacrobatPushRack +from .rnr_rack import MacrobatRnRRack +from .sidechain_rack import MacrobatSidechainRack +from .parameter_racks import ( + MacrobatLearnRack, MacrobatChainMixRack, MacrobatDRMultiRack, + MacrobatDRRack, MacrobatReceiverRack, MacrobatTrackRack, + MacrobatChainSelectorRack, MacrobatDRPadMixRack, +) + + +MACROBAT_RACKS = OrderedDict([ + ('NK RECEIVER', MacrobatReceiverRack), # param rack + ('NK TRACK', MacrobatTrackRack), # param rack + ('NK DR PAD MIX', MacrobatDRPadMixRack), # param rack + ('NK DR MULTI', MacrobatDRMultiRack), # param rack + ('NK CHAIN MIX', MacrobatChainMixRack), # param rack + ('NK DR', MacrobatDRRack), # param rack + ('NK LEARN', MacrobatLearnRack), # param rack + ('NK MIDI', MacrobatMidiRack), + ('NK RST', MacrobatRnRRack), # RnR + ('NK RND', MacrobatRnRRack), # RnR + ('NK SIDECHAIN', MacrobatSidechainRack), + ('NK SCL', MacrobatPushRack), + ('NK CS', MacrobatChainSelectorRack), # param rack +]) + + +# {param: (mess_type, reset)} +RNR_ON_OFF = dict([ + ('NK RND ALL', ('all', False)), + ('NK RND', ('next', False)), + ('NK RST ALL', ('all', True)), + ('NK RST', ('next', True)), +]) diff --git a/src/clyphx/macrobat/macrobat.py b/src/clyphx/macrobat/macrobat.py index c4cc9c2..f417304 100644 --- a/src/clyphx/macrobat/macrobat.py +++ b/src/clyphx/macrobat/macrobat.py @@ -15,7 +15,7 @@ # along with ClyphX. If not, see . from __future__ import absolute_import, unicode_literals -from builtins import super +from builtins import super, dict from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -23,15 +23,6 @@ from ..core.live import Device, RackDevice, Track from ..core.xcomponent import XComponent -from .midi_rack import MacrobatMidiRack -from .rnr_rack import MacrobatRnRRack -from .sidechain_rack import MacrobatSidechainRack -from .parameter_racks import ( - MacrobatLearnRack, MacrobatChainMixRack, MacrobatDRMultiRack, - MacrobatDRRack, MacrobatReceiverRack, MacrobatTrackRack, - MacrobatChainSelectorRack, MacrobatDRPadMixRack, -) -from .push_rack import MacrobatPushRack class Macrobat(XComponent): @@ -126,39 +117,39 @@ def get_devices(self, dev_list): def setup_macrobat_rack(self, rack): # type: (RackDevice) -> None '''Setup Macrobat rack if meets criteria.''' + from .consts import MACROBAT_RACKS + if rack.class_name.endswith('GroupDevice'): name = rack.name.upper() - m = None - if name.startswith('NK RECEIVER'): - m = MacrobatReceiverRack(self._parent, rack, self._track) - elif name.startswith('NK TRACK') and not self._track.has_midi_output: - m = MacrobatTrackRack(self._parent, rack, self._track) - elif name.startswith('NK DR PAD MIX'): - m = MacrobatDRPadMixRack(self._parent, rack, self._track) - elif self._parent._can_have_nested_devices: - if name.startswith('NK DR MULTI'): - m = MacrobatDRMultiRack(self._parent, rack, self._track) - elif name.startswith('NK CHAIN MIX'): - m = MacrobatChainMixRack(self._parent, rack, self._track) - elif name.startswith('NK DR'): - m = MacrobatDRRack(self._parent, rack, self._track) - elif (name.startswith('NK LEARN') and - self._track == self.song().master_track and - not self._has_learn_rack): - m = MacrobatLearnRack(self._parent, rack, self._track) - self._has_learn_rack = True - elif name.startswith('NK MIDI'): - m = MacrobatMidiRack(self._parent, rack, name) - elif name.startswith(('NK RST', 'NK RND')): - m = MacrobatRnRRack(self._parent, rack, name, self._track) - elif name.startswith('NK SIDECHAIN'): - m = MacrobatSidechainRack(self._parent, rack, self._track) - elif name.startswith('NK SCL'): - m = MacrobatPushRack(self._parent, rack) - elif name.startswith('NK CS'): - m = MacrobatChainSelectorRack(self._parent, rack, self._track) - if m: - self._current_devices.append((m, rack)) + for key, cls in MACROBAT_RACKS.items(): + if name.startswith(key): + break + else: + return None + + # checks + if key == 'NK TRACK' and self._track.has_midi_output: + return None + elif (key in ('NK DR MULTI', 'NK CHAIN MIX', 'NK DR', 'NK LEARN') + and not self._parent._can_have_nested_devices): + return None + elif key == 'NK LEARN': + if self._track != self.song().master_track or self._has_learn_rack: + return None + self._has_learn_rack = True + + # instances + if key == 'NK MIDI': + args = self._parent, rack, name + elif key in ('NK RST', 'NK RND'): + args = self._parent, rack, name, self._track + elif key == 'NK SCL': + args = self._parent, rack + else: + # all param racks and push rack + args= self._parent, rack, self._track + + self._current_devices.append((cls(*args), rack)) def remove_devices(self, dev_list): # type: (Iterable[RackDevice]) -> None diff --git a/src/clyphx/macrobat/midi_rack.py b/src/clyphx/macrobat/midi_rack.py index 2fdd654..69813e7 100644 --- a/src/clyphx/macrobat/midi_rack.py +++ b/src/clyphx/macrobat/midi_rack.py @@ -17,6 +17,8 @@ from __future__ import absolute_import, unicode_literals from builtins import super from typing import TYPE_CHECKING +import logging +import re from ..core.xcomponent import XComponent from .user_config import SYSEX_LIST @@ -25,6 +27,14 @@ from typing import Optional, Any, Text, List, Dict from ..core.live import Device +log = logging.getLogger(__name__) + +# channel an CC patterns, now they are case-insensitive and accept an +# optional whitespace before the number, e.g. [CC 3], [CC5], [ch 120] +# (range validation is done in methods) +RE_CH = re.compile(r'\[ch\s?(\d{1,2})\]', re.I) +RE_CC = re.compile(r'\[cc\s?(\d{1,3})\]', re.I) + class MacrobatMidiRack(XComponent): '''Macros To Midi CCs + PCs + SysEx. @@ -147,29 +157,36 @@ def check_sysex_list(self, name_string): def check_for_channel(self, name): # type: (Text) -> int - '''Check for user-specified channel in rack name.''' - result = 0 - if '[CH' in name and ']' in name and not name.count('[') > 1 and not name.count(']') > 1: + '''Check for user-specified channel in rack name. + ''' + if name.count('[') == name.count(']') == 1: + value = RE_CH.search(name) try: - get_ch = int(name[name.index('[')+3:name.index(']')]) - if 1 <= get_ch < 17: - result = get_ch - 1 - except: + ch = int(value.group(1)) + if 1 <= ch < 17: + return cc - 1 + except AttributeError: pass - return result + except ValueError: + log.error("Invalid MIDI channel number: '%d'", value) + return 0 def check_for_cc_num(self, name): # type: (Text) -> Optional[int] - '''Check for user-specified CC# in macro name.''' - result = None - if '[CC' in name and ']' in name and not name.count('[') > 1 and not name.count(']') > 1: + '''Check for user-specified CC# in macro name. + ''' + if name.count('[') == name.count(']') == 1: + value = RE_CC.search(name) try: - get_cc = int(name[name.index('[')+3:name.index(']')]) - if 0 <= get_cc < 128: - result = get_cc - except: + cc = int(value.group(1)) + if 0 <= cc < 128: + return cc + raise ValueError + except AttributeError: pass - return result + except ValueError: + log.error("Invalid MIDI CC number: '%d'", value) + return None def remove_macro_listeners(self): '''Remove listeners.''' diff --git a/src/clyphx/macrobat/parameter_rack_template.py b/src/clyphx/macrobat/parameter_rack_template.py index b241223..250ed8f 100644 --- a/src/clyphx/macrobat/parameter_rack_template.py +++ b/src/clyphx/macrobat/parameter_rack_template.py @@ -57,14 +57,6 @@ 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 on_off_changed(self): - '''On/off changed, schedule param reset.''' - if self._on_off_param and self._on_off_param[0]: - if (self._on_off_param[0].value != self._on_off_param[1] - and self._on_off_param[0].value == 1.0): - self._parent.schedule_message(1, self.do_reset) - self._on_off_param[1] = self._on_off_param[0].value - def scale_macro_value_to_param(self, macro, param): # type: (DeviceParameter, DeviceParameter) -> float return (((param.max - param.min) / 127.0) * macro.value) + param.min @@ -73,6 +65,21 @@ def scale_param_value_to_macro(self, param): # type: (DeviceParameter) -> int return int(((param.value - param.min) / (param.max - param.min)) * 127.0) + def get_initial_value(self, arg=None): + # type: (None) -> None + '''Get initial values to set macros to.''' + for i in range(1, 9): + try: + # TODO: indexed dict? + param = self._param_macros[i] + except KeyError: + pass + else: + if param[0] and param[1]: + value = self.scale_param_value_to_macro(param[1]) + if param[0].value != value: + param[0].value = value + def get_drum_rack(self): '''For use with DR racks, get drum rack to operate on as well as the params of any simplers/samplers in the rack. @@ -95,26 +102,13 @@ def get_drum_rack(self): break return drum_rack - def macro_changed(self, index): - # type: (int) -> None - '''Called on macro changes to update param values.''' - if index in self._param_macros and self._param_macros[index][0] and self._param_macros[index][1]: - scaled_value = self.scale_param_value_to_macro(self._param_macros[index][1]) - if scaled_value != self._param_macros[index][0].value: - self._update_param = index - self._tasks.kill() - self._tasks.clear() - self._tasks.add(self.update_param) - - def update_macro(self, arg=None): - # type: (None) -> None - '''Update macro to match value of param.''' - macro = self._param_macros.get(self._update_macro) - if macro: - if macro[0] and macro[1]: - macro[0].value = self.scale_param_value_to_macro(macro[1]) - self._tasks.kill() - self._tasks.clear() + def on_off_changed(self): + '''On/off changed, schedule param reset.''' + if self._on_off_param and self._on_off_param[0]: + if (self._on_off_param[0].value != self._on_off_param[1] + and self._on_off_param[0].value == 1.0): + self._parent.schedule_message(1, self.do_reset) + self._on_off_param[1] = self._on_off_param[0].value def set_param_macro_listeners(self, macro, param, index): # type: (DeviceParameter, DeviceParameter, int) -> None @@ -142,6 +136,27 @@ def remove_macro_listeners(self): self._on_off_param[0].remove_value_listener(self.on_off_changed) self._on_off_param = [] + def macro_changed(self, index): + # type: (int) -> None + '''Called on macro changes to update param values.''' + if index in self._param_macros and self._param_macros[index][0] and self._param_macros[index][1]: + scaled_value = self.scale_param_value_to_macro(self._param_macros[index][1]) + if scaled_value != self._param_macros[index][0].value: + self._update_param = index + self._tasks.kill() + self._tasks.clear() + self._tasks.add(self.update_param) + + def update_macro(self, arg=None): + # type: (None) -> None + '''Update macro to match value of param.''' + macro = self._param_macros.get(self._update_macro) + if macro: + if macro[0] and macro[1]: + macro[0].value = self.scale_param_value_to_macro(macro[1]) + self._tasks.kill() + self._tasks.clear() + def param_changed(self, index): # type: (int) -> None '''Called on param changes to update macros.''' @@ -167,21 +182,6 @@ def update_param(self, arg=None): self._tasks.kill() self._tasks.clear() - def get_initial_value(self, arg=None): - # type: (None) -> None - '''Get initial values to set macros to.''' - for i in range(1, 9): - try: - # TODO: indexed dict? - param = self._param_macros[i] - except KeyError: - pass - else: - if param[0] and param[1]: - value = self.scale_param_value_to_macro(param[1]) - if param[0].value != value: - param[0].value = value - def do_reset(self): '''Reset assigned params to default.''' self._update_param = 0 diff --git a/src/clyphx/macrobat/push_rack.py b/src/clyphx/macrobat/push_rack.py index 79a9983..2d4ef6b 100644 --- a/src/clyphx/macrobat/push_rack.py +++ b/src/clyphx/macrobat/push_rack.py @@ -19,7 +19,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any + from typing import Any, Number from ..core.live import RackDevice from ..core.xcomponent import XComponent @@ -94,11 +94,11 @@ def _on_macro_two_value(self): def _handle_scale_type_change(self, args=None): # type: (None) -> None if self._push_ins: - mode_list = self._script._scales_enabler._mode_map['enabled'].mode._component._scale_list.scrollable_list - current_type = self._script._scales_enabler._mode_map['enabled'].mode._component._scale_list.scrollable_list.selected_item_index + component = self._script._scales_enabler._mode_map['enabled'].mode._component + mode_list = component._scale_list.scrollable_list new_type = self.scale_macro_value_to_param(self._rack.parameters[2], len(mode_list.items)) - if new_type != current_type: + if new_type != mode_list.selected_item_index: # != current_type mode_list._set_selected_item_index(new_type) self._update_scale_display_and_buttons() self._parent.schedule_message(1, self._update_rack_name) @@ -107,8 +107,9 @@ def _update_scale_display_and_buttons(self): '''Updates Push's scale display and buttons to indicate current settings. ''' - self._script._scales_enabler._mode_map['enabled'].mode._component._update_data_sources() - self._script._scales_enabler._mode_map['enabled'].mode._component.update() + component = self._script._scales_enabler._mode_map['enabled'].mode._component + component._update_data_sources() + component.update() def _update_rack_name(self): '''Update rack name to reflect selected root note and scale type. @@ -120,6 +121,7 @@ def _update_rack_name(self): ) def scale_macro_value_to_param(self, 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 2f75fb8..ec07932 100644 --- a/src/clyphx/macrobat/rnr_rack.py +++ b/src/clyphx/macrobat/rnr_rack.py @@ -51,8 +51,8 @@ def setup_device(self, rack, name): ''' - Will not reset/randomize any other Macrobat racks except for MIDI Rack - - Allowable rack names are: ['NK RST', 'NK RST ALL', 'NK RND', - 'NK RND ALL'] + - Allowable rack names are: ['nK RST', 'nK RST ALL', 'nK RND', + 'nK RND ALL'] ''' self.remove_on_off_listeners() if rack: @@ -69,21 +69,18 @@ def setup_device(self, rack, name): def on_off_changed(self): '''On/off changed, perform assigned function.''' + from .consts import RNR_ON_OFF + if self._on_off_param and self._on_off_param[0]: - is_reset = False - if self._on_off_param[1].startswith('NK RND ALL'): - mess_type = 'all' - elif self._on_off_param[1].startswith('NK RND'): - mess_type = 'next' - elif self._on_off_param[1].startswith('NK RST ALL'): - mess_type = 'all' - is_reset = True - elif self._on_off_param[1].startswith('NK RST'): - mess_type = 'next' - is_reset = True + name = self._on_off_param[1].upper() + for k, v in RNR_ON_OFF.items(): + if name.startswith(k): + mess_type, reset = v + break else: - return - action = self.do_device_reset if is_reset else self.do_device_randomize + return None + + action = self.do_device_reset if reset else self.do_device_randomize self._parent.schedule_message(1, partial(action, mess_type)) def do_device_randomize(self, params): diff --git a/src/clyphx/macrobat/sidechain_rack.py b/src/clyphx/macrobat/sidechain_rack.py index bd8dc29..a3ed444 100644 --- a/src/clyphx/macrobat/sidechain_rack.py +++ b/src/clyphx/macrobat/sidechain_rack.py @@ -43,11 +43,13 @@ def __init__(self, parent, rack, track): def disconnect(self): if self._track: - if self._track.has_audio_output and self._track.output_meter_left_has_listener(self.audio_left_changed): - self._track.remove_output_meter_left_listener(self.audio_left_changed) - if self._track.has_audio_output and self._track.output_meter_right_has_listener(self.audio_right_changed): - self._track.remove_output_meter_right_listener(self.audio_right_changed) - if self._track.has_midi_output and self._track.output_meter_level_has_listener(self.midi_changed): + if self._track.has_audio_output: + if self._track.output_meter_left_has_listener(self.audio_left_changed): + self._track.remove_output_meter_left_listener(self.audio_left_changed) + if self._track.output_meter_right_has_listener(self.audio_right_changed): + self._track.remove_output_meter_right_listener(self.audio_right_changed) + if (self._track.has_midi_output + and self._track.output_meter_level_has_listener(self.midi_changed)): self._track.remove_output_meter_level_listener(self.midi_changed) self._track = None self._rack = None @@ -66,7 +68,8 @@ def setup_device(self): self._track.add_output_meter_left_listener(self.audio_left_changed) if not self._track.output_meter_right_has_listener(self.audio_right_changed): self._track.add_output_meter_right_listener(self.audio_right_changed) - if self._track.has_midi_output and not self._track.output_meter_level_has_listener(self.midi_changed): + if (self._track.has_midi_output + and not self._track.output_meter_level_has_listener(self.midi_changed)): self._track.add_output_meter_level_listener(self.midi_changed) def audio_left_changed(self): diff --git a/src/clyphx/push_apc_combiner.py b/src/clyphx/push_apc_combiner.py index 107ebe7..246e37c 100644 --- a/src/clyphx/push_apc_combiner.py +++ b/src/clyphx/push_apc_combiner.py @@ -18,11 +18,17 @@ from builtins import super from ableton.v2.control_surface.components.session_ring import SessionRingComponent +from ableton.v2.control_surface.elements import TouchEncoderElement +from _APC.RingedEncoderElement import RingedEncoderElement +from APC40 import APC40 +from Push import Push + 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 class PushApcCombiner(XComponent): @@ -31,8 +37,9 @@ class PushApcCombiner(XComponent): __module__ = __name__ def __init__(self, parent): + # type: (Any) -> None super().__init__(parent) - self._push = None + self._push = None # type: Optional[MidiRemoteScript] self._push_session = None self._apc = None self._apc_session = None @@ -46,32 +53,31 @@ def disconnect(self): super().disconnect() def set_up_scripts(self, scripts): - # type: (Iterable[Any]) -> None + # type: (Iterable[MidiRemoteScript]) -> None '''Remove current listeners, get Push/APC scripts, set up listeners and also set feedback delay on APC+Push encoders. ''' self._remove_listeners() for script in scripts: - script_name = script.__class__.__name__ - if script_name == 'Push': + if isinstance(script, Push): self._push = script self._push_session = self._get_session_component(script) if self._push_session: for c in script.controls: - if c.__class__.__name__ == 'TouchEncoderElement': + if isinstance(c, TouchEncoderElement): c.set_feedback_delay(-1) - elif script_name == 'APC40': + elif isinstance(script, APC40): self._apc = script self._apc_session = self._get_session_component(script) if self._apc_session: for c in script.controls: - if c.__class__.__name__ == 'RingedEncoderElement': + if isinstance(c, RingedEncoderElement): c.set_feedback_delay(-1) self._apc_session.add_offset_listener(self._on_apc_offset_changed) self._on_apc_offset_changed() def _get_session_component(self, script): - # type: (Any) -> Optional[Any] + # type: (MidiRemoteScript) -> Optional[Any] '''Get the session component for the given script. ''' if script and script._components: diff --git a/src/clyphx/triggers/__init__.py b/src/clyphx/triggers/__init__.py new file mode 100644 index 0000000..21e8cb5 --- /dev/null +++ b/src/clyphx/triggers/__init__.py @@ -0,0 +1,7 @@ +from __future__ import absolute_import + +from .base import ActionList +from .clip import get_xclip_action_list +from .control import XControlComponent +from .track import XTrackComponent +from .cue import XCueComponent diff --git a/src/clyphx/triggers/base.py b/src/clyphx/triggers/base.py new file mode 100644 index 0000000..592ac49 --- /dev/null +++ b/src/clyphx/triggers/base.py @@ -0,0 +1,41 @@ +# coding: utf-8 +from __future__ import absolute_import, unicode_literals +from builtins import object, super +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Text + +from _Framework.SubjectSlot import subject_slot + +from ..core.xcomponent import XComponent + + +class ActionList(object): + '''Allows X-Triggers with no name to trigger Action Lists. It can + also be used to trigger ClyphX Actions via UserActions. + ''' + __module__ = __name__ + + def __init__(self, name='none'): + # type: (Text) -> None + self.name = name + + +class XTrigger(XComponent): + def __init__(self, parent): + # type: (Any) -> None + super().__init__(parent) + + @subject_slot('name') + def _on_name_changed(self): + self.updated() + + @property + def ref_track(self): + '''The track indicated as SEL. + + 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 diff --git a/src/clyphx/triggers/clip.py b/src/clyphx/triggers/clip.py new file mode 100644 index 0000000..bb94865 --- /dev/null +++ b/src/clyphx/triggers/clip.py @@ -0,0 +1,79 @@ +# coding: utf-8 +from __future__ import absolute_import, unicode_literals +from builtins import super +from typing import TYPE_CHECKING +import logging + +if TYPE_CHECKING: + from typing import Text + from ..core.live import Clip + +from .base import XTrigger + +log = logging.getLogger(__name__) + + +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. + 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. + ''' + log.info('get_xclip_action_list(xclip=%r, full_action_list=%s', + xclip, full_action_list) + split_list = full_action_list.split(':') + + if xclip.is_playing: + result = split_list[0] + elif len(split_list) == 2: + if split_list[1].strip() == '*': + result = split_list[0] + else: + result = split_list[1] + else: + # FIXME: shouldn't be None + result = '' + + return result + + +class XClip(XTrigger): + + can_have_off_list = True + __module__ = __name__ + + def __init__(self, parent, clip): + # type: (Any, Clip) -> None + super().__init__(parent) + self._clip = clip + self._on_name_changed.subject = self._clip + + def update(self): + super().update() + + @property + def is_playing(self): + # type: () -> bool + return self._clip.is_playing + + @property + def in_loop_seq(self): + # type: () -> bool + return self.name in self._parent._loop_seq_clips + + @property + def in_play_seq(self): + # type: () -> bool + return self.name in self._parent._play_seq_clips + + @property + def ref_track(self): + '''The track indicated as SEL. + + It's the song's selected track for all triggers except for + X-Clips, where is the host track. + ''' + raise NotImplementedError diff --git a/src/clyphx/triggers/control.py b/src/clyphx/triggers/control.py new file mode 100644 index 0000000..b31d354 --- /dev/null +++ b/src/clyphx/triggers/control.py @@ -0,0 +1,128 @@ +# coding: utf-8 +from __future__ import absolute_import, unicode_literals +from builtins import super +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Text, Dict, Iterable, Sequence, List, Tuple + from ..core.live import MidiRemoteScript + +from .base import XTrigger, ActionList +from ..core.models import UserControl +from ..core.live import forward_midi_cc, forward_midi_note + + +class XControlComponent(XTrigger): + '''A control on a MIDI controller. + ''' + __module__ = __name__ + + def __init__(self, parent): + # type: (Any) -> None + super().__init__(parent) + self._control_list = dict() # type: Dict[Tuple[int, int], Dict[Text, Any]] + self._xt_scripts = [] # type: List[Any] + + def disconnect(self): + self._control_list = dict() + self._xt_scripts = [] + super().disconnect() + + def connect_script_instances(self, instantiated_scripts): + # type: (Iterable[MidiRemoteScript]) -> None + '''Try to connect to ClyphX_XT instances.''' + for i in range(5): + try: + if i == 0: + from ClyphX_XTA.ClyphX_XT import ClyphX_XT + elif i == 1: + from ClyphX_XTB.ClyphX_XT import ClyphX_XT + elif i == 2: + from ClyphX_XTC.ClyphX_XT import ClyphX_XT + elif i == 3: + from ClyphX_XTD.ClyphX_XT import ClyphX_XT + elif i == 4: + from ClyphX_XTE.ClyphX_XT import ClyphX_XT + else: + continue + except ImportError: + pass + else: + if ClyphX_XT: + for i in instantiated_scripts: + if isinstance(i, ClyphX_XT) and not i in self._xt_scripts: + self._xt_scripts.append(i) + break + + def assign_new_actions(self, string): + # type: (Text) -> None + '''Assign new actions to controls via xclips.''' + if self._xt_scripts: + for x in self._xt_scripts: + x.assign_new_actions(string) + ident = string[string.index('[')+2:string.index(']')].strip() + actions = string[string.index(']')+2:].strip() + for c, v in self._control_list.items(): + if ident == v['ident']: + new_actions = actions.split(',') + on_action = '[{}] {}'.format(ident, new_actions[0]) + off_action = None + if on_action and len(new_actions) > 1: + if new_actions[1].strip() == '*': + off_action = on_action + else: + off_action = '[{}] {}'.format(ident, new_actions[1]) + if on_action: + v['on_action'] = on_action + v['off_action'] = off_action + break + + def receive_midi(self, bytes): + # type: (Sequence[int]) -> None + '''Receive user-defined midi messages.''' + if self._control_list: + ctrl_data = None + if bytes[2] == 0 or bytes[0] < 144: + if ((bytes[0], bytes[1]) in self._control_list.keys() and + self._control_list[(bytes[0], bytes[1])]['off_action']): + ctrl_data = self._control_list[(bytes[0], bytes[1])] + elif ((bytes[0] + 16, bytes[1]) in self._control_list.keys() and + self._control_list[(bytes[0] + 16, bytes[1])]['off_action']): + ctrl_data = self._control_list[(bytes[0] + 16, bytes[1])] + if ctrl_data: + ctrl_data['name'].name = ctrl_data['off_action'] + elif bytes[2] != 0 and (bytes[0], bytes[1]) in self._control_list.keys(): + 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']) + + def get_user_controls(self, settings, midi_map_handle): + # type: (Dict[Text, Text], int) -> None + self._control_list = dict() + for name, data in settings.items(): + uc = UserControl.parse(name, data) + self._control_list[uc._key] = dict( + ident = name, + on_action = uc.on_actions, + off_action = uc.off_actions, + name = ActionList(uc.on_actions) + ) + fn = forward_midi_note if uc.status_byte == 144 else forward_midi_cc + fn(self._parent._c_instance.handle(), midi_map_handle, uc.channel, uc.value) + + def rebuild_control_map(self, midi_map_handle): + # type: (int) -> None + '''Called from main when build_midi_map is called.''' + for key in self._control_list.keys(): + if key[0] >= 176: + # forwards a CC msg to the receive_midi method + forward_midi_cc( + self._parent._c_instance.handle(), midi_map_handle, key[0] - 176, key[1] + ) + else: + # forwards a NOTE msg to the receive_midi method + forward_midi_note( + self._parent._c_instance.handle(), midi_map_handle, key[0] - 144, key[1] + ) diff --git a/src/clyphx/triggers/cue.py b/src/clyphx/triggers/cue.py new file mode 100644 index 0000000..68011d1 --- /dev/null +++ b/src/clyphx/triggers/cue.py @@ -0,0 +1,97 @@ +# coding: utf-8 +from __future__ import absolute_import, unicode_literals +from builtins import super +from typing import TYPE_CHECKING +from functools import partial + +if TYPE_CHECKING: + from typing import Any, Text, List, Dict + +from .base import XTrigger + + +class XCueComponent(XTrigger): + '''Cue component that monitors cue points and calls main script on + changes. + ''' + __module__ = __name__ + + def __init__(self, parent): + # type: (Any) -> None + super().__init__(parent) + self.song().add_current_song_time_listener(self.arrange_time_changed) + self.song().add_is_playing_listener(self.arrange_time_changed) + self.song().add_cue_points_listener(self.cue_points_changed) + self._x_points = dict() # type: Dict[Any, Any] + self._x_point_time_to_watch_for = -1 + self._last_arrange_position = -1 + self._sorted_times = [] # type: List[Any] + self.cue_points_changed() + + def disconnect(self): + self.remove_cue_point_listeners() + self.song().remove_current_song_time_listener(self.arrange_time_changed) + self.song().remove_is_playing_listener(self.arrange_time_changed) + self.song().remove_cue_points_listener(self.cue_points_changed) + self._x_points = dict() + super().disconnect() + + def cue_points_changed(self): + '''Called on cue point changes to set up points to watch, cue + points can't be named via the API so cue points can't perform + any actions requiring naming. + ''' + self.remove_cue_point_listeners() + self._sorted_times = [] + for cp in self.song().cue_points: + if not cp.time_has_listener(self.cue_points_changed): + cp.add_time_listener(self.cue_points_changed) + if not cp.name_has_listener(self.cue_points_changed): + cp.add_name_listener(self.cue_points_changed) + name = cp.name.upper() + if len(name) > 2 and name[0] == '[' and name.count('[') == 1 and name.count(']') == 1: + cue_name = name.replace(name[name.index('['):name.index(']')+1].strip(), '') + self._x_points[cp.time] = cp + self._sorted_times = sorted(self._x_points.keys()) + self.set_x_point_time_to_watch() + + def arrange_time_changed(self): + '''Called on arrange time changed and schedules actions where + necessary. + ''' + if self.song().is_playing: + if (self._x_point_time_to_watch_for != -1 + and self._last_arrange_position < self.song().current_song_time): + if (self.song().current_song_time >= self._x_point_time_to_watch_for and + self._x_point_time_to_watch_for < self._last_arrange_position): + self._parent.schedule_message( + 1, partial(self.schedule_x_point_action_list, self._x_point_time_to_watch_for) + ) + self._x_point_time_to_watch_for = -1 + else: + self.set_x_point_time_to_watch() + self._last_arrange_position = self.song().current_song_time + + def set_x_point_time_to_watch(self): + '''Determine which cue point time to watch for next.''' + if self._x_points: + if self.song().is_playing: + for t in self._sorted_times: + if t >= self.song().current_song_time: + self._x_point_time_to_watch_for = t + break + else: + 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]) + + def remove_cue_point_listeners(self): + for cp in self.song().cue_points: + if cp.time_has_listener(self.cue_points_changed): + cp.remove_time_listener(self.cue_points_changed) + if cp.name_has_listener(self.cue_points_changed): + cp.remove_name_listener(self.cue_points_changed) + self._x_points = dict() + self._x_point_time_to_watch_for = -1 diff --git a/src/clyphx/triggers/track.py b/src/clyphx/triggers/track.py new file mode 100644 index 0000000..c6104d2 --- /dev/null +++ b/src/clyphx/triggers/track.py @@ -0,0 +1,101 @@ +# coding: utf-8 +from __future__ import absolute_import, unicode_literals +from builtins import super +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Text, List + from ..core.live import Clip, Track + +from .base import XTrigger + + +class XTrackComponent(XTrigger): + '''Track component that monitors play slot index and calls main + script on changes. + ''' + __module__ = __name__ + + def __init__(self, parent, track): + # type: (Any, Track) -> None + super().__init__(parent) + self._track = track + self._clip = None + self._loop_count = 0 + self._track.add_playing_slot_index_listener(self.play_slot_index_changed) + self._register_timer_callback(self.on_timer) + self._last_slot_index = -1 + self._triggered_clips = [] # type: List[Clip] + self._triggered_lseq_clip = None + + def disconnect(self): + self.remove_loop_jump_listener() + self._unregister_timer_callback(self.on_timer) + if self._track and self._track.playing_slot_index_has_listener(self.play_slot_index_changed): + self._track.remove_playing_slot_index_listener(self.play_slot_index_changed) + self._track = None + self._clip = None + self._triggered_clips = [] + self._triggered_lseq_clip = None + super().disconnect() + + def play_slot_index_changed(self): + '''Called on track play slot index changes to set up clips to + trigger (on play and stop) and set up loop listener for LSEQ. + ''' + self.remove_loop_jump_listener() + 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: + self._triggered_clips.append(prev_clip) + self._clip = new_clip + 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) + + def get_xclip(self, slot_index): + # type: (int) -> Optional[Clip] + '''Get the xclip associated with slot_index or None.''' + clip = None + if self._track and 0 <= slot_index < len(self._track.clip_slots): + 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 + if len(clip_name) > 2 and clip_name[0] == '[' and ']' in clip_name: + clip = slot.clip + return clip + + def on_loop_jump(self): + '''Called on loop changes to increment loop count and set clip + to trigger. + ''' + self._loop_count += 1 + if self._clip: + self._triggered_lseq_clip = self._clip + + def on_timer(self): + '''Continuous timer, calls main script if there are any + triggered clips. + ''' + if self._track and (not self._track.mute or + self._parent._process_xclips_if_track_muted): + if self._triggered_clips: + for clip in self._triggered_clips: + self._parent.handle_action_list_trigger(self._track, clip) + self._triggered_clips = [] + if self._triggered_lseq_clip: + self._parent.handle_loop_seq_action_list(self._triggered_lseq_clip, + self._loop_count) + self._triggered_lseq_clip = None + + def remove_loop_jump_listener(self): + self._loop_count = 0 + if self._clip and self._clip.loop_jump_has_listener(self.on_loop_jump): + self._clip.remove_loop_jump_listener(self.on_loop_jump) diff --git a/src/clyphx/user_actions.py b/src/clyphx/user_actions.py index f290e15..27071d3 100644 --- a/src/clyphx/user_actions.py +++ b/src/clyphx/user_actions.py @@ -84,7 +84,7 @@ import logging from .core.xcomponent import XComponent -from .core.legacy import ActionList +from .triggers import ActionList if TYPE_CHECKING: from typing import Any, Text, Dict, List, Optional diff --git a/src/clyphx/user_config.py b/src/clyphx/user_config.py index 02d6d06..9625859 100644 --- a/src/clyphx/user_config.py +++ b/src/clyphx/user_config.py @@ -1,6 +1,6 @@ # coding: utf-8 # -# Copyright 2020-2021 Nuno André Novo +# Copyright (c) 2020-2021 Nuno André Novo # Some rights reserved. See COPYING, COPYING.LESSER # SPDX-License-Identifier: LGPL-2.1-or-later @@ -58,16 +58,24 @@ class UserSettings(object): ''' def __init__(self, *filepaths): # type: (Text) -> None + self._vars = None for path in filepaths: self._parse_config(path) @property def vars(self): - return self._getattrs(['user_variables', 'variables'], fallback=dict()) + if self._vars is None: + self._vars = UserVars() + valid_section_names = ['user_variables', 'variables'] + config = self._getattrs(valid_section_names, fallback=dict()) + for k, v in config.items(): + self._vars[k] = v + return self._vars @property def prefs(self): - return self._getattrs(['extra_prefs', 'general_settings'], fallback=dict()) + valid_section_names = ['extra_prefs', 'general_settings'] + return self._getattrs(valid_section_names, fallback=dict()) def _getattrs(self, attrs, fallback=unset): for attr in attrs: @@ -132,48 +140,85 @@ def get_user_settings(): from .core.utils import get_base_path import os - filepath = os.path.expanduser('~/ClyphX/UserSettings.txt') - if os.path.exists(filepath): - return UserSettings(filepath) + for func, arg in [ + # (os.path.expandvars, '$CLYPHX_CONFIG'), + (os.path.expanduser, '~/ClyphX/UserSettings.txt'), + (get_base_path, 'UserSettings.txt'), + ]: + filepath = func(arg) + if os.path.exists(filepath): + log.info('Reading settings from %s', filepath) + return UserSettings(filepath) - filepath = get_base_path('UserSettings.txt') - if os.path.exists(filepath): - return UserSettings(filepath) - - filepath = os.path.expandvars('$CLYPHX_CONFIG') - if os.path.exists(filepath) and os.path.isfile(filepath): - return UserSettings(filepath) - - raise Exception('User settings not found.') + raise OSError('User settings not found.') # TODO class UserVars(object): + '''User vars container. + + Var names are case-insensitive and should not contain characters + other than letters, numbers, and underscores. + ''' _vars = dict() # type: Dict[Text, Text] + # new format: %VARNAME% + re_var = re.compile(r'%(\w+?)%') + + # legacy format: $VARNAME + re_legacy_var = re.compile(r'\$(\w+?)\b') + def __getitem__(self, key): - # TODO - return self._vars.get(key, '0') + # (Text) -> Text + try: + key = key.group(1) + except AttributeError: + pass + try: + return self._vars[key.lower()] + except KeyError: + log.warning("Var '%s' not found. Defaults to '0'.", key) + return '0' def __setitem__(self, key, value): - self.add_var(key, value) + # type: (Text, Text) -> None + self._add_var(key.lower(), value) - def add_var(self, name, value): + def _add_var(self, name, value): # type: (Text, Text) -> None - value = str(value) - if not any(x in value for x in (';', '%', '=')): - if '(' in value and ')' in value: - try: - value = eval(value) - except Exception as e: - log.error('Evaluation error: %s=%s', name, value) - return - self._vars[name] = str(value) - log.debug('User variable assigned: %s=%s', name, value) - - def resolve_vars(self, string): + value = self.sub(str(value)) + + if any(x in value for x in (';', '%', '=')): + err = "Invalid assignment: {} = {}" + raise ValueError(err.format(name, value)) + + if '(' in value and ')' in value: + value = eval(value) + self._vars[name] = str(value) + log.debug('User variable assigned: %s=%s', name, value) + + def add(self, statement): + # type: (Text) -> None + '''Evaluates the expression (either an assignment or an + expression enclosed in parens) and stores the result. + ''' + statement = statement.replace('$', '') # legacy vars compat + try: + key, val = [x.strip() for x in statement.split('=')] + self._add_var(key, val) + except Exception as e: + log.error("Failed to evaluate '%s': %r", statement, e) + + def sub(self, string): # type: (Text) -> Text - '''Replace any user variables in the given string with the value - the variable represents. + '''Replace any user variables in the given string with their + stored value. ''' - pass + try: + if '%' in string: + return self.re_var.sub(self.__getitem__, string) + elif '$' in string: + return self.re_legacy_var.sub(self.__getitem__, string) + except Exception as e: + log.error("Failed to substitute '%s': %r", string, e) + return string diff --git a/src/clyphx/xtriggers.py b/src/clyphx/xtriggers.py deleted file mode 100644 index 08a5252..0000000 --- a/src/clyphx/xtriggers.py +++ /dev/null @@ -1,333 +0,0 @@ -# -*- coding: utf-8 -*- -# This file is part of ClyphX. -# -# ClyphX is free software: you can redistribute it and/or modify it under the -# 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 -# more details. -# -# 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 -from builtins import super, dict, range -from typing import TYPE_CHECKING -from functools import partial -import logging - -from .core.xcomponent import XComponent -from .core.legacy import ActionList -from .core.models import UserControl -from .core.live import forward_midi_cc, forward_midi_note - -if TYPE_CHECKING: - from typing import ( - Any, Optional, Text, Dict, - Iterable, Sequence, List, Tuple, - ) - from .core.live import Clip, Track - -log = logging.getLogger(__name__) - - -class XTrigger(XComponent): - pass - - -class XControlComponent(XTrigger): - '''A control on a MIDI controller. - ''' - __module__ = __name__ - - def __init__(self, parent): - # type: (Any) -> None - super().__init__(parent) - self._control_list = dict() # type: Dict[Tuple[int, int], Dict[Text, Any]] - self._xt_scripts = [] # type: List[Any] - - def disconnect(self): - self._control_list = dict() - self._xt_scripts = [] - super().disconnect() - - def connect_script_instances(self, instantiated_scripts): - # type: (Iterable[Any]) -> None - '''Try to connect to ClyphX_XT instances.''' - for i in range(5): - try: - if i == 0: - from ClyphX_XTA.ClyphX_XT import ClyphX_XT - elif i == 1: - from ClyphX_XTB.ClyphX_XT import ClyphX_XT - elif i == 2: - from ClyphX_XTC.ClyphX_XT import ClyphX_XT - elif i == 3: - from ClyphX_XTD.ClyphX_XT import ClyphX_XT - elif i == 4: - from ClyphX_XTE.ClyphX_XT import ClyphX_XT - else: - continue - except ImportError: - pass - else: - if ClyphX_XT: - for i in instantiated_scripts: - if isinstance(i, ClyphX_XT) and not i in self._xt_scripts: - self._xt_scripts.append(i) - break - - def assign_new_actions(self, string): - # type: (Text) -> None - '''Assign new actions to controls via xclips.''' - if self._xt_scripts: - for x in self._xt_scripts: - x.assign_new_actions(string) - ident = string[string.index('[')+2:string.index(']')].strip() - actions = string[string.index(']')+2:].strip() - for c, v in self._control_list.items(): - if ident == v['ident']: - new_actions = actions.split(',') - on_action = '[{}] {}'.format(ident, new_actions[0]) - off_action = None - if on_action and len(new_actions) > 1: - if new_actions[1].strip() == '*': - off_action = on_action - else: - off_action = '[{}] {}'.format(ident, new_actions[1]) - if on_action: - v['on_action'] = on_action - v['off_action'] = off_action - break - - def receive_midi(self, bytes): - # type: (Sequence[int]) -> None - '''Receive user-defined midi messages.''' - if self._control_list: - ctrl_data = None - if bytes[2] == 0 or bytes[0] < 144: - if ((bytes[0], bytes[1]) in self._control_list.keys() and - self._control_list[(bytes[0], bytes[1])]['off_action']): - ctrl_data = self._control_list[(bytes[0], bytes[1])] - elif ((bytes[0] + 16, bytes[1]) in self._control_list.keys() and - self._control_list[(bytes[0] + 16, bytes[1])]['off_action']): - ctrl_data = self._control_list[(bytes[0] + 16, bytes[1])] - if ctrl_data: - ctrl_data['name'].name = ctrl_data['off_action'] - elif bytes[2] != 0 and (bytes[0], bytes[1]) in self._control_list.keys(): - 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']) - - def get_user_controls(self, settings, midi_map_handle): - # type: (Dict[Text, Text], Any) -> None - self._control_list = dict() - for name, data in settings.items(): - uc = UserControl.parse(name, data) - self._control_list[uc._key] = dict( - ident = name, - on_action = uc.on_actions, - off_action = uc.off_actions, - name = ActionList(uc.on_actions) - ) - fn = forward_midi_note if uc.status_byte == 144 else forward_midi_cc - fn(self._parent._c_instance.handle(), midi_map_handle, uc.channel, uc.value) - - def rebuild_control_map(self, midi_map_handle): - # type: (int) -> None - '''Called from main when build_midi_map is called.''' - log.debug('XControlComponent.rebuild_control_map') - for key in self._control_list.keys(): - if key[0] >= 176: - # forwards a CC msg to the receive_midi method - forward_midi_cc( - self._parent._c_instance.handle(), midi_map_handle, key[0] - 176, key[1] - ) - else: - # forwards a NOTE msg to the receive_midi method - forward_midi_note( - self._parent._c_instance.handle(), midi_map_handle, key[0] - 144, key[1] - ) - - -class XTrackComponent(XTrigger): - '''Track component that monitors play slot index and calls main - script on changes. - ''' - __module__ = __name__ - - def __init__(self, parent, track): - # type: (Any, Track) -> None - super().__init__(parent) - self._track = track - self._clip = None - self._loop_count = 0 - self._track.add_playing_slot_index_listener(self.play_slot_index_changed) - self._register_timer_callback(self.on_timer) - self._last_slot_index = -1 - self._triggered_clips = [] # type: List[Clip] - self._triggered_lseq_clip = None - - def disconnect(self): - self.remove_loop_jump_listener() - self._unregister_timer_callback(self.on_timer) - if self._track and self._track.playing_slot_index_has_listener(self.play_slot_index_changed): - self._track.remove_playing_slot_index_listener(self.play_slot_index_changed) - self._track = None - self._clip = None - self._triggered_clips = [] - self._triggered_lseq_clip = None - super().disconnect() - - def play_slot_index_changed(self): - '''Called on track play slot index changes to set up clips to - trigger (on play and stop) and set up loop listener for LSEQ. - ''' - self.remove_loop_jump_listener() - 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: - self._triggered_clips.append(prev_clip) - self._clip = new_clip - 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) - - def get_xclip(self, slot_index): - # type: (int) -> Optional[Clip] - '''Get the xclip associated with slot_index or None.''' - clip = None - if self._track and 0 <= slot_index < len(self._track.clip_slots): - 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 - if len(clip_name) > 2 and clip_name[0] == '[' and ']' in clip_name: - clip = slot.clip - return clip - - def on_loop_jump(self): - '''Called on loop changes to increment loop count and set clip - to trigger. - ''' - self._loop_count += 1 - if self._clip: - self._triggered_lseq_clip = self._clip - - def on_timer(self): - '''Continuous timer, calls main script if there are any - triggered clips. - ''' - if self._track and (not self._track.mute or - self._parent._process_xclips_if_track_muted): - if self._triggered_clips: - for clip in self._triggered_clips: - self._parent.handle_action_list_trigger(self._track, clip) - self._triggered_clips = [] - if self._triggered_lseq_clip: - self._parent.handle_loop_seq_action_list(self._triggered_lseq_clip, - self._loop_count) - self._triggered_lseq_clip = None - - def remove_loop_jump_listener(self): - self._loop_count = 0 - if self._clip and self._clip.loop_jump_has_listener(self.on_loop_jump): - self._clip.remove_loop_jump_listener(self.on_loop_jump) - - -class XCueComponent(XTrigger): - '''Cue component that monitors cue points and calls main script on - changes. - ''' - __module__ = __name__ - - def __init__(self, parent): - # type: (Any) -> None - super().__init__(parent) - self.song().add_current_song_time_listener(self.arrange_time_changed) - self.song().add_is_playing_listener(self.arrange_time_changed) - self.song().add_cue_points_listener(self.cue_points_changed) - self._x_points = dict() # type: Dict[Text, Any] - self._x_point_time_to_watch_for = -1 - self._last_arrange_position = -1 - self._sorted_times = [] # type: List[Any] - self.cue_points_changed() - - def disconnect(self): - self.remove_cue_point_listeners() - self.song().remove_current_song_time_listener(self.arrange_time_changed) - self.song().remove_is_playing_listener(self.arrange_time_changed) - self.song().remove_cue_points_listener(self.cue_points_changed) - self._x_points = dict() - super().disconnect() - - def cue_points_changed(self): - '''Called on cue point changes to set up points to watch, cue - points can't be named via the API so cue points can't perform - any actions requiring naming. - ''' - self.remove_cue_point_listeners() - self._sorted_times = [] - for cp in self.song().cue_points: - if not cp.time_has_listener(self.cue_points_changed): - cp.add_time_listener(self.cue_points_changed) - if not cp.name_has_listener(self.cue_points_changed): - cp.add_name_listener(self.cue_points_changed) - name = cp.name.upper() - if len(name) > 2 and name[0] == '[' and name.count('[') == 1 and name.count(']') == 1: - cue_name = name.replace(name[name.index('['):name.index(']')+1].strip(), '') - self._x_points[cp.time] = cp - self._sorted_times = sorted(self._x_points.keys()) - self.set_x_point_time_to_watch() - - def arrange_time_changed(self): - '''Called on arrange time changed and schedules actions where - necessary. - ''' - if self.song().is_playing: - if self._x_point_time_to_watch_for != -1 and self._last_arrange_position < self.song().current_song_time: - if (self.song().current_song_time >= self._x_point_time_to_watch_for and - self._x_point_time_to_watch_for < self._last_arrange_position): - self._parent.schedule_message( - 1, partial(self.schedule_x_point_action_list, self._x_point_time_to_watch_for) - ) - self._x_point_time_to_watch_for = -1 - else: - self.set_x_point_time_to_watch() - self._last_arrange_position = self.song().current_song_time - - def set_x_point_time_to_watch(self): - '''Determine which cue point time to watch for next.''' - if self._x_points: - if self.song().is_playing: - for t in self._sorted_times: - if t >= self.song().current_song_time: - self._x_point_time_to_watch_for = t - break - else: - 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]) - - def remove_cue_point_listeners(self): - for cp in self.song().cue_points: - if cp.time_has_listener(self.cue_points_changed): - cp.remove_time_listener(self.cue_points_changed) - if cp.name_has_listener(self.cue_points_changed): - cp.remove_name_listener(self.cue_points_changed) - self._x_points = dict() - self._x_point_time_to_watch_for = -1 diff --git a/tests/test_lexer.py b/tests/test_lexer.py index eb2c05d..ab4275d 100644 --- a/tests/test_lexer.py +++ b/tests/test_lexer.py @@ -1,31 +1,28 @@ -from __future__ import absolute_import, unicode_literals +# from __future__ import absolute_import, unicode_literals -from pathlib import Path -import sys +# from pathlib import Path +# import sys -here = Path(__file__).parents[1].joinpath('src/clyphx') -sys.path.insert(0, str(here)) +# here = Path(__file__).parents[1].joinpath('src/clyphx') +# sys.path.insert(0, str(here)) -import re -from collections import ChainMap -from core.vendor.retoken import Scanner -from core.tokens import OBJECT, VIEW, ACTION, CONTINOUS_PARAM_VALUE, IDENTIFIER +# import re +# from collections import ChainMap +# from vendor.retoken import Scanner +# from core.tokens import OBJECT, VIEW, ACTION, CONTINOUS_PARAM_VALUE, IDENTIFIER -ACTIONS = [ - 'ADDAUDIO 10', -] +# ACTIONS = [ +# 'ADDAUDIO 10', +# ] -def test_sth(): - LEXICON = ChainMap(OBJECT, VIEW, ACTION, CONTINOUS_PARAM_VALUE, IDENTIFIER) +# def test_sth(): +# LEXICON = ChainMap(OBJECT, VIEW, ACTION, CONTINOUS_PARAM_VALUE, IDENTIFIER) - scanner = Scanner(LEXICON.items(), flags=re.I) +# scanner = Scanner(LEXICON.items(), flags=re.I) - for action in ACTIONS: - for token, match in scanner.scan_with_holes(action): - if token: - print(token, match.group()) - - -# test_sth() +# for action in ACTIONS: +# for token, match in scanner.scan_with_holes(action): +# if token: +# print(token, match.group()) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index db96df3..509b355 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -66,7 +66,7 @@ def test_user_controls(): # region COMMAND PARSER TEST -def test_tracks(): +def test_specs(): from clyphx.core.parse import Parser parse = Parser() diff --git a/tools/win.ps1 b/tools/win.ps1 index 2dc0330ea16aeee7d518b7cbf53e5d8649b84ce4..743f356bcbe35babd27af2252de0b4d97094f9e5 100644 GIT binary patch delta 106 zcmca4Gf8ek0n_Avj2@G>FojKC#v;V3z@WmQFz+(ik!sDuC=# qh9a=2K9CKOEdYw-Gh_kPz(n*I5*c!Uy3`poCm&={+^oi|!wmq