├── .coveragerc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── XTouchMini.png ├── appveyor.yml ├── codecov.yml ├── docs ├── Makefile ├── environment.yml ├── make.bat └── source │ ├── _static │ └── helper.js │ ├── conf.py │ ├── develop-install.rst │ ├── examples │ ├── example.nblink │ └── index.rst │ ├── index.rst │ └── installing.rst ├── examples └── Example.ipynb ├── ipymidicontrols.json ├── ipymidicontrols ├── __init__.py ├── _frontend.py ├── _version.py ├── nbextension │ ├── __init__.py │ └── static │ │ └── extension.js ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_example.py │ └── test_nbextension_path.py └── xtouchmini.py ├── package.json ├── pytest.ini ├── readthedocs.yml ├── setup.cfg ├── setup.py ├── setupbase.py ├── src ├── enableMIDI.ts ├── extension.ts ├── index.ts ├── midi.ts ├── plugin.ts ├── version.ts ├── widget.ts └── xtouchmini │ ├── button.ts │ ├── buttongroup.ts │ ├── disposable.ts │ ├── fader.ts │ ├── index.ts │ ├── rotary.ts │ ├── utils.ts │ └── xtouchmini.ts ├── tests ├── karma.conf.js ├── src │ ├── index.spec.ts │ └── utils.spec.ts └── tsconfig.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = ipymidicontrols/tests/* 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask instance folder 58 | instance/ 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | docs/source/_static/embed-bundle.js 66 | docs/source/_static/embed-bundle.js.map 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # IPython Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # dotenv 81 | .env 82 | 83 | # virtualenv 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # ========================= 94 | # Operating System Files 95 | # ========================= 96 | 97 | # OSX 98 | # ========================= 99 | 100 | .DS_Store 101 | .AppleDouble 102 | .LSOverride 103 | 104 | # Thumbnails 105 | ._* 106 | 107 | # Files that might appear in the root of a volume 108 | .DocumentRevisions-V100 109 | .fseventsd 110 | .Spotlight-V100 111 | .TemporaryItems 112 | .Trashes 113 | .VolumeIcon.icns 114 | 115 | # Directories potentially created on remote AFP share 116 | .AppleDB 117 | .AppleDesktop 118 | Network Trash Folder 119 | Temporary Items 120 | .apdisk 121 | 122 | # Windows 123 | # ========================= 124 | 125 | # Windows image file caches 126 | Thumbs.db 127 | ehthumbs.db 128 | 129 | # Folder config file 130 | Desktop.ini 131 | 132 | # Recycle Bin used on file shares 133 | $RECYCLE.BIN/ 134 | 135 | # Windows Installer files 136 | *.cab 137 | *.msi 138 | *.msm 139 | *.msp 140 | 141 | # Windows shortcuts 142 | *.lnk 143 | 144 | 145 | # NPM 146 | # ---- 147 | 148 | **/node_modules/ 149 | ipymidicontrols/nbextension/static/index.* 150 | ipymidicontrols/labextension/*.tgz 151 | 152 | # Coverage data 153 | # ------------- 154 | **/coverage/ 155 | 156 | # Packed lab extensions 157 | ipymidicontrols/labextension 158 | 159 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | tests/ 4 | .jshintrc 5 | # Ignore any build output from python: 6 | dist/*.tar.gz 7 | dist/*.wheel 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.6 4 | - 3.5 5 | - 3.4 6 | sudo: false 7 | addons: 8 | apt_packages: 9 | - pandoc 10 | env: 11 | matrix: 12 | - GROUP=python 13 | matrix: 14 | include: 15 | - python: 3.5 16 | env: GROUP=js 17 | include: 18 | - python: 3.6 19 | env: GROUP=docs 20 | cache: 21 | pip: true 22 | directories: 23 | - node_modules # NPM packages 24 | - $HOME/.npm 25 | before_install: 26 | - pip install -U pip setuptools 27 | - nvm install 8 28 | - | 29 | if [[ $GROUP == python ]]; then 30 | pip install codecov 31 | elif [[ $GROUP == js ]]; then 32 | npm install -g codecov 33 | fi 34 | install: 35 | - | 36 | if [[ $GROUP == python ]]; then 37 | pip install --upgrade ".[test]" -v 38 | elif [[ $GROUP == js ]]; then 39 | pip install --upgrade -e ".[test]" -v 40 | elif [[ $GROUP == docs ]]; then 41 | pip install --upgrade ".[test, examples, docs]" -v 42 | fi 43 | before_script: 44 | # Set up a virtual screen for Firefox browser testing: 45 | - | 46 | if [[ $GROUP == js ]]; then 47 | export CHROME_BIN=chromium-browser 48 | export DISPLAY=:99.0 49 | sh -e /etc/init.d/xvfb start 50 | fi 51 | git config --global user.email travis@fake.com 52 | git config --global user.name "Travis CI" 53 | script: 54 | - | 55 | if [[ $GROUP == python ]]; then 56 | EXIT_STATUS=0 57 | pushd $(mktemp -d) 58 | py.test -l --cov-report xml:$TRAVIS_BUILD_DIR/coverage.xml --cov=ipymidicontrols --pyargs ipymidicontrols || EXIT_STATUS=$? 59 | popd 60 | (exit $EXIT_STATUS) 61 | elif [[ $GROUP == js ]]; then 62 | npm test 63 | elif [[ $GROUP == docs ]]; then 64 | EXIT_STATUS=0 65 | cd docs 66 | make html || EXIT_STATUS=$? 67 | make linkcheck || EXIT_STATUS=$? 68 | cd .. 69 | python -m pytest_check_links --links-ext=.md -o testpaths=. -o addopts= || EXIT_STATUS=$? 70 | (exit $EXIT_STATUS) 71 | fi 72 | after_success: 73 | - codecov 74 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Project Jupyter Contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.md 3 | 4 | include setupbase.py 5 | # include pytest.ini 6 | # include .coveragerc 7 | 8 | # include package.json 9 | # include webpack.config.js 10 | include ipymidicontrols/labextension/*.tgz 11 | 12 | # Documentation 13 | graft docs 14 | exclude docs/\#* 15 | prune docs/build 16 | prune docs/gh-pages 17 | prune docs/dist 18 | 19 | # Examples 20 | graft examples 21 | 22 | # Tests 23 | # graft tests 24 | # prune tests/build 25 | 26 | # Javascript files 27 | graft ipymidicontrols/nbextension 28 | # graft src 29 | prune **/node_modules 30 | prune coverage 31 | prune lib 32 | 33 | # Patterns to exclude from any directory 34 | global-exclude *~ 35 | global-exclude *.pyc 36 | global-exclude *.pyo 37 | global-exclude .git 38 | global-exclude .ipynb_checkpoints 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # midicontrols 3 | 4 | [![Build Status](https://travis-ci.org/jupyter-widgets/midicontrols.svg?branch=master)](https://travis-ci.org/jupyter-widgets/ipymidicontrols) 5 | [![codecov](https://codecov.io/gh/jupyter-widgets/midicontrols/branch/master/graph/badge.svg)](https://codecov.io/gh/jupyter-widgets/midicontrols) 6 | 7 | 8 | A Jupyter widget for interfacing with MIDI controllers. 9 | 10 | ## Installation 11 | 12 | You can install using `pip`: 13 | 14 | ```bash 15 | pip install ipymidicontrols 16 | ``` 17 | 18 | Or if you use jupyterlab: 19 | 20 | ```bash 21 | pip install ipymidicontrols 22 | jupyter labextension install @jupyter-widgets/jupyterlab-manager 23 | ``` 24 | 25 | If you are using Jupyter Notebook 5.2 or earlier, you may also need to enable 26 | the nbextension: 27 | ```bash 28 | jupyter nbextension enable --py [--sys-prefix|--user|--system] ipymidicontrols 29 | ``` 30 | 31 | ## Usage 32 | 33 | Create a controller widget for a [Behringer XTouch Mini](https://www.musictribe.com/Categories/Behringer/Computer-Audio/Desktop-Controllers/X-TOUCH-MINI/p/P0B3M): 34 | 35 | ```python 36 | from ipymidicontrols import XTouchMini 37 | x = XTouchMini() 38 | ``` 39 | 40 | See a simple widgets-based UI for the controls: 41 | 42 | ```python 43 | from ipymidicontrols import xtouchmini_ui 44 | xtouchmini_ui(x) 45 | ``` 46 | 47 | ![screenshot](https://raw.githubusercontent.com/jupyter-widgets/midicontrols/master/XTouchMini.png) 48 | -------------------------------------------------------------------------------- /XTouchMini.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-widgets/midicontrols/54e1cc31842984c7e4b2e9b52960abb7546d25f8/XTouchMini.png -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # Do not build feature branch with open Pull Requests 2 | skip_branch_with_pr: true 3 | 4 | # environment variables 5 | environment: 6 | nodejs_version: "8" 7 | matrix: 8 | - PYTHON: "C:\\Miniconda3-x64" 9 | PYTHON_VERSION: "3.7" 10 | PYTHON_MAJOR: 3 11 | PYTHON_ARCH: "64" 12 | - PYTHON: "C:\\Miniconda3" 13 | PYTHON_VERSION: "3.4" 14 | PYTHON_MAJOR: 3 15 | PYTHON_ARCH: "32" 16 | 17 | # build cache to preserve files/folders between builds 18 | cache: 19 | - '%AppData%/npm-cache' 20 | - '%PYTHON%/pkgs' 21 | - '%LOCALAPPDATA%\pip\Cache' 22 | 23 | # scripts that run after cloning repository 24 | install: 25 | # Install node: 26 | - ps: Install-Product node $env:nodejs_version 27 | # Ensure python scripts are from right version: 28 | - 'SET "PATH=%PYTHON%\Scripts;%PYTHON%;%PATH%"' 29 | # Setup conda: 30 | - 'conda list' 31 | - 'conda update conda -y' 32 | # If 32 bit, force conda to use it: 33 | - 'IF %PYTHON_ARCH% EQU 32 SET CONDA_FORCE_32BIT=1' 34 | - 'conda create -n test_env python=%PYTHON_VERSION% -y' 35 | - 'activate test_env' 36 | # Update install tools: 37 | - 'conda install setuptools pip -y' 38 | - 'python -m pip install --upgrade pip' 39 | - 'python -m easy_install --upgrade setuptools' 40 | # Install coverage utilities: 41 | - 'pip install codecov' 42 | # Install our package: 43 | - 'pip install --upgrade ".[test]" -v' 44 | 45 | build: off 46 | 47 | # scripts to run before tests 48 | before_test: 49 | - git config --global user.email appveyor@fake.com 50 | - git config --global user.name "AppVeyor CI" 51 | - set "tmptestdir=%tmp%\ipymidicontrols-%RANDOM%" 52 | - mkdir "%tmptestdir%" 53 | - cd "%tmptestdir%" 54 | 55 | 56 | # to run your custom scripts instead of automatic tests 57 | test_script: 58 | - 'py.test -l --cov-report xml:"%APPVEYOR_BUILD_FOLDER%\coverage.xml" --cov=ipymidicontrols --pyargs ipymidicontrols' 59 | 60 | on_success: 61 | - cd "%APPVEYOR_BUILD_FOLDER%" 62 | - codecov -X gcov --file "%APPVEYOR_BUILD_FOLDER%\coverage.xml" 63 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | # show coverage in CI status, but never consider it a failure 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: 0% 8 | patch: 9 | default: 10 | target: 0% 11 | ignore: 12 | - "ipymidicontrols/tests" 13 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = ipymidicontrols 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/environment.yml: -------------------------------------------------------------------------------- 1 | 2 | name: ipymidicontrols_docs 3 | channels: 4 | - conda-forge 5 | dependencies: 6 | - python=3 7 | - nodejs 8 | - yarn 9 | - numpy 10 | - sphinx 11 | - nbsphinx 12 | - jupyter_sphinx 13 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=ipymidicontrols 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/source/_static/helper.js: -------------------------------------------------------------------------------- 1 | var cache_require = window.require; 2 | 3 | window.addEventListener('load', function() { 4 | window.require = cache_require; 5 | }); 6 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # ipymidicontrols documentation build configuration file 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | 16 | # -- General configuration ------------------------------------------------ 17 | 18 | # If your documentation needs a minimal Sphinx version, state it here. 19 | # 20 | # needs_sphinx = '1.0' 21 | 22 | # Add any Sphinx extension module names here, as strings. They can be 23 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 24 | # ones. 25 | extensions = [ 26 | 'sphinx.ext.autodoc', 27 | 'sphinx.ext.viewcode', 28 | 'sphinx.ext.intersphinx', 29 | 'sphinx.ext.napoleon', 30 | 'sphinx.ext.todo', 31 | 'nbsphinx', 32 | 'nbsphinx_link', 33 | ] 34 | 35 | # Ensure our extension is available: 36 | import sys 37 | from os.path import dirname, join as pjoin 38 | docs = dirname(dirname(__file__)) 39 | root = dirname(docs) 40 | sys.path.insert(0, root) 41 | sys.path.insert(0, pjoin(docs, 'sphinxext')) 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ['_templates'] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # 49 | # source_suffix = ['.rst', '.md'] 50 | source_suffix = '.rst' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = 'ipymidicontrols' 57 | copyright = '2018, Project Jupyter Contributors' 58 | author = 'Project Jupyter Contributors' 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | 66 | 67 | # get version from python package: 68 | import os 69 | here = os.path.dirname(__file__) 70 | repo = os.path.join(here, '..', '..') 71 | _version_py = os.path.join(repo, 'ipymidicontrols', '_version.py') 72 | version_ns = {} 73 | with open(_version_py) as f: 74 | exec(f.read(), version_ns) 75 | 76 | # The short X.Y version. 77 | version = '%i.%i' % version_ns['version_info'][:2] 78 | # The full version, including alpha/beta/rc tags. 79 | release = version_ns['__version__'] 80 | 81 | # The language for content autogenerated by Sphinx. Refer to documentation 82 | # for a list of supported languages. 83 | # 84 | # This is also used if you do content translation via gettext catalogs. 85 | # Usually you set "language" from the command line for these cases. 86 | language = None 87 | 88 | # List of patterns, relative to source directory, that match files and 89 | # directories to ignore when looking for source files. 90 | # This patterns also effect to html_static_path and html_extra_path 91 | exclude_patterns = ['**.ipynb_checkpoints'] 92 | 93 | # The name of the Pygments (syntax highlighting) style to use. 94 | pygments_style = 'sphinx' 95 | 96 | # If true, `todo` and `todoList` produce output, else they produce nothing. 97 | todo_include_todos = False 98 | 99 | 100 | # -- Options for HTML output ---------------------------------------------- 101 | 102 | 103 | # Theme options are theme-specific and customize the look and feel of a theme 104 | # further. For a list of options available for each theme, see the 105 | # documentation. 106 | # 107 | # html_theme_options = {} 108 | 109 | # Add any paths that contain custom static files (such as style sheets) here, 110 | # relative to this directory. They are copied after the builtin static files, 111 | # so a file named "default.css" will overwrite the builtin "default.css". 112 | html_static_path = ['_static'] 113 | 114 | 115 | # -- Options for HTMLHelp output ------------------------------------------ 116 | 117 | # Output file base name for HTML help builder. 118 | htmlhelp_basename = 'ipymidicontrolsdoc' 119 | 120 | 121 | # -- Options for LaTeX output --------------------------------------------- 122 | 123 | latex_elements = { 124 | # The paper size ('letterpaper' or 'a4paper'). 125 | # 126 | # 'papersize': 'letterpaper', 127 | 128 | # The font size ('10pt', '11pt' or '12pt'). 129 | # 130 | # 'pointsize': '10pt', 131 | 132 | # Additional stuff for the LaTeX preamble. 133 | # 134 | # 'preamble': '', 135 | 136 | # Latex figure (float) alignment 137 | # 138 | # 'figure_align': 'htbp', 139 | } 140 | 141 | # Grouping the document tree into LaTeX files. List of tuples 142 | # (source start file, target name, title, 143 | # author, documentclass [howto, manual, or own class]). 144 | latex_documents = [ 145 | (master_doc, 'ipymidicontrols.tex', 'ipymidicontrols Documentation', 146 | 'Project Jupyter Contributors', 'manual'), 147 | ] 148 | 149 | 150 | # -- Options for manual page output --------------------------------------- 151 | 152 | # One entry per manual page. List of tuples 153 | # (source start file, name, description, authors, manual section). 154 | man_pages = [ 155 | (master_doc, 156 | 'ipymidicontrols', 157 | 'ipymidicontrols Documentation', 158 | [author], 1) 159 | ] 160 | 161 | 162 | # -- Options for Texinfo output ------------------------------------------- 163 | 164 | # Grouping the document tree into Texinfo files. List of tuples 165 | # (source start file, target name, title, author, 166 | # dir menu entry, description, category) 167 | texinfo_documents = [ 168 | (master_doc, 169 | 'ipymidicontrols', 170 | 'ipymidicontrols Documentation', 171 | author, 172 | 'ipymidicontrols', 173 | 'A Jupyter widget for interfacing with MIDI controllers.', 174 | 'Miscellaneous'), 175 | ] 176 | 177 | 178 | # Example configuration for intersphinx: refer to the Python standard library. 179 | intersphinx_mapping = {'https://docs.python.org/': None} 180 | 181 | # Read The Docs 182 | # on_rtd is whether we are on readthedocs.org, this line of code grabbed from 183 | # docs.readthedocs.org 184 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 185 | 186 | if not on_rtd: # only import and set the theme if we're building docs locally 187 | import sphinx_rtd_theme 188 | html_theme = 'sphinx_rtd_theme' 189 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 190 | 191 | # otherwise, readthedocs.org uses their theme by default, so no need to specify it 192 | 193 | 194 | # Uncomment this line if you have know exceptions in your included notebooks 195 | # that nbsphinx complains about: 196 | # 197 | nbsphinx_allow_errors = True # exception ipstruct.py ipython_genutils 198 | 199 | # nbsphinx will not have access to a controller, so executing will not work. 200 | nbsphinx_execute = 'never' -------------------------------------------------------------------------------- /docs/source/develop-install.rst: -------------------------------------------------------------------------------- 1 | 2 | Developer install 3 | ================= 4 | 5 | 6 | To install a developer version of ipymidicontrols, you will first need to clone 7 | the repository:: 8 | 9 | git clone https://github.com/jupyter-widgets/midicontrols 10 | cd midicontrols 11 | 12 | Next, install it with a develop install using pip:: 13 | 14 | pip install -e . 15 | 16 | 17 | If you are planning on working on the JS/frontend code, you should also do 18 | a link installation of the extension:: 19 | 20 | jupyter nbextension install [--sys-prefix / --user / --system] --symlink --py ipymidicontrols 21 | 22 | jupyter nbextension enable [--sys-prefix / --user / --system] --py ipymidicontrols 23 | 24 | with the `appropriate flag`_. Or, if you are using Jupyterlab:: 25 | 26 | jupyter labextension install . 27 | 28 | 29 | .. links 30 | 31 | .. _`appropriate flag`: https://jupyter-notebook.readthedocs.io/en/stable/extending/frontend_extensions.html#installing-and-enabling-extensions 32 | -------------------------------------------------------------------------------- /docs/source/examples/example.nblink: -------------------------------------------------------------------------------- 1 | { 2 | "path": "../../../examples/Example.ipynb" 3 | } 4 | -------------------------------------------------------------------------------- /docs/source/examples/index.rst: -------------------------------------------------------------------------------- 1 | 2 | Examples 3 | ======== 4 | 5 | .. toctree:: 6 | :glob: 7 | 8 | * 9 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | 2 | ipymidicontrols 3 | ===================================== 4 | 5 | Version: |release| 6 | 7 | A Jupyter widget for interfacing with MIDI controllers. 8 | 9 | Because Chrome is the only browser that implements the `Web MIDI API `__, this package only works in Chrome. Firefox has `recent discussion `__ on how to move forward with implementing this standard. The webmidi JavaScript package mentions there is a Firefox plugin that possibly makes this work in Firefox (see `https://www.npmjs.com/package/webmidi#browser-support `__). 10 | 11 | Each midi controller needs a custom implementation exposing the interface for that specific midi controller as buttons, knobs, faders, etc. Currently we support the `Behringer X-Touch Mini `__ controller, which is currently available for around $60. 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | :caption: Installation and usage 16 | 17 | installing 18 | 19 | .. toctree:: 20 | :maxdepth: 1 21 | 22 | examples/index 23 | 24 | 25 | .. toctree:: 26 | :maxdepth: 2 27 | :caption: Development 28 | 29 | develop-install 30 | 31 | 32 | .. links 33 | 34 | .. _`Jupyter widgets`: https://jupyter.org/widgets.html 35 | 36 | .. _`notebook`: https://jupyter-notebook.readthedocs.io/en/latest/ 37 | -------------------------------------------------------------------------------- /docs/source/installing.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _installation: 3 | 4 | Installation 5 | ============ 6 | 7 | 8 | The simplest way to install ipymidicontrols is via pip:: 9 | 10 | pip install ipymidicontrols 11 | 12 | or via conda:: 13 | 14 | conda install ipymidicontrols 15 | 16 | 17 | If you installed via pip, and notebook version < 5.3, you will also have to 18 | install / configure the front-end extension as well. If you are using classic 19 | notebook (as opposed to Jupyterlab), run:: 20 | 21 | jupyter nbextension install [--sys-prefix / --user / --system] --py ipymidicontrols 22 | 23 | jupyter nbextension enable [--sys-prefix / --user / --system] --py ipymidicontrols 24 | 25 | with the `appropriate flag`_. If you are using Jupyterlab, install the extension 26 | with:: 27 | 28 | jupyter labextension install @jupyter-widgets/midicontrols 29 | 30 | In JupyterLab, you will also need to install the ipywidgets extension:: 31 | 32 | jupyter labextension install @jupyter-widgets/jupyterlab-manager 33 | 34 | 35 | .. links 36 | 37 | .. _`appropriate flag`: https://jupyter-notebook.readthedocs.io/en/stable/extending/frontend_extensions.html#installing-and-enabling-extensions 38 | -------------------------------------------------------------------------------- /examples/Example.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Behringer X-Touch Mini\n" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "This notebook can be downloaded [here](https://github.com/jupyter-widgets/midicontrols/blob/master/examples/Example.ipynb)." 15 | ] 16 | }, 17 | { 18 | "cell_type": "markdown", 19 | "metadata": {}, 20 | "source": [ 21 | "Because Chrome is the only browser that implements the [Web MIDI API](https://developer.mozilla.org/en-US/docs/Web/API/MIDIAccess), this package only works in Chrome. Firefox has [recent discussion](https://bugzilla.mozilla.org/show_bug.cgi?id=836897) on how to move forward with implementing this standard.\n", 22 | "\n", 23 | "Each midi controller needs a custom implementation exposing the interface for that specific midi controller as buttons, knobs, faders, etc. Currently we support the [Behringer X-Touch Mini](https://www.behringer.com/Categories/Behringer/Computer-Audio/Desktop-Controllers/X-TOUCH-MINI/p/P0B3M#googtrans(en|en)) controller, which is currently available for around \\$60." 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": null, 29 | "metadata": {}, 30 | "outputs": [], 31 | "source": [ 32 | "from ipymidicontrols import XTouchMini, xtouchmini_ui\n", 33 | "x = XTouchMini()" 34 | ] 35 | }, 36 | { 37 | "cell_type": "markdown", 38 | "metadata": {}, 39 | "source": [ 40 | "We can work directly with the controls to assign values, listen for value changes, etc., just like a normal widget. Run the cell below, then turn the first knob or press the upper left button. You should see the values below update. Note that the button value toggles when held down, and the light on the physical button reflects this state, where true means light on, false means light off." 41 | ] 42 | }, 43 | { 44 | "cell_type": "code", 45 | "execution_count": null, 46 | "metadata": {}, 47 | "outputs": [], 48 | "source": [ 49 | "left_knob = x.rotary_encoders[0]\n", 50 | "upper_left_button = x.buttons[0]\n", 51 | "display(left_knob)\n", 52 | "display(upper_left_button)" 53 | ] 54 | }, 55 | { 56 | "cell_type": "markdown", 57 | "metadata": {}, 58 | "source": [ 59 | "You can also adjust the values from Python and the changes are reflected in the kernel." 60 | ] 61 | }, 62 | { 63 | "cell_type": "code", 64 | "execution_count": null, 65 | "metadata": {}, 66 | "outputs": [], 67 | "source": [ 68 | "left_knob.value = 50" 69 | ] 70 | }, 71 | { 72 | "cell_type": "markdown", 73 | "metadata": {}, 74 | "source": [ 75 | "## Rotary encoders (knobs)" 76 | ] 77 | }, 78 | { 79 | "cell_type": "markdown", 80 | "metadata": {}, 81 | "source": [ 82 | "Rotary encoders (i.e., knobs) have a min and max that can be set.\n" 83 | ] 84 | }, 85 | { 86 | "cell_type": "code", 87 | "execution_count": null, 88 | "metadata": {}, 89 | "outputs": [], 90 | "source": [ 91 | "left_knob.min=0\n", 92 | "left_knob.max=10" 93 | ] 94 | }, 95 | { 96 | "cell_type": "markdown", 97 | "metadata": {}, 98 | "source": [ 99 | "Knobs have a variety of ways to display the value in the lights around the knob. If your value represents a deviation from some reference, you might use the `'trim'` light mode. If your value represents the width of a symmetric range around some reference, you might use the `'spread'` light mode." 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": null, 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "# light_mode can be 'single', 'wrap', 'trim', 'spread'\n", 109 | "left_knob.light_mode = 'spread'" 110 | ] 111 | }, 112 | { 113 | "cell_type": "markdown", 114 | "metadata": {}, 115 | "source": [ 116 | "We'll set the min/max back to the default (0, 100) range for the rest of the example for consistency with other knobs." 117 | ] 118 | }, 119 | { 120 | "cell_type": "code", 121 | "execution_count": null, 122 | "metadata": {}, 123 | "outputs": [], 124 | "source": [ 125 | "left_knob.min = 0\n", 126 | "left_knob.max = 100" 127 | ] 128 | }, 129 | { 130 | "cell_type": "markdown", 131 | "metadata": {}, 132 | "source": [ 133 | "## Buttons" 134 | ] 135 | }, 136 | { 137 | "cell_type": "markdown", 138 | "metadata": {}, 139 | "source": [ 140 | "Since the button has a True/False state, and holding down the button momentarily toggles the state, if we set the button to True when it is not held down, we reverse the toggling (i.e., it is now True by default, and pressing it toggles it to False)." 141 | ] 142 | }, 143 | { 144 | "cell_type": "code", 145 | "execution_count": null, 146 | "metadata": {}, 147 | "outputs": [], 148 | "source": [ 149 | "upper_left_button.value = True\n", 150 | "# Now press the button to see it toggle to false." 151 | ] 152 | }, 153 | { 154 | "cell_type": "markdown", 155 | "metadata": {}, 156 | "source": [ 157 | "We can change this toggling behavior in the button by setting the button `mode`. It defaults to `'momentary'`, which means the button state toggles only when the button is held down. Setting `mode` to `'toggle'` makes the button toggle its value each time it is pressed. Run the following cell and press the button several times. Notice how the toggle behavior is different." 158 | ] 159 | }, 160 | { 161 | "cell_type": "code", 162 | "execution_count": null, 163 | "metadata": {}, 164 | "outputs": [], 165 | "source": [ 166 | "upper_left_button.mode = 'toggle'" 167 | ] 168 | }, 169 | { 170 | "cell_type": "markdown", 171 | "metadata": {}, 172 | "source": [ 173 | "Each rotary encoder can also be pressed as a button and the toggle mode can be set as well. Run the cell below and press the left knob." 174 | ] 175 | }, 176 | { 177 | "cell_type": "code", 178 | "execution_count": null, 179 | "metadata": {}, 180 | "outputs": [], 181 | "source": [ 182 | "left_knob_button = x.rotary_buttons[0]\n", 183 | "left_knob_button.mode = 'toggle'\n", 184 | "display(left_knob_button)" 185 | ] 186 | }, 187 | { 188 | "cell_type": "markdown", 189 | "metadata": {}, 190 | "source": [ 191 | "## Faders\n", 192 | "\n", 193 | "The fader can send its value to Python and has `min`, `max`, and `value` properties." 194 | ] 195 | }, 196 | { 197 | "cell_type": "code", 198 | "execution_count": null, 199 | "metadata": {}, 200 | "outputs": [], 201 | "source": [ 202 | "fader = x.faders[0]\n", 203 | "display(fader)" 204 | ] 205 | }, 206 | { 207 | "cell_type": "markdown", 208 | "metadata": {}, 209 | "source": [ 210 | "Because the X-Touch Mini does not have motorized faders, the fader cannot be moved to represent a value set from Python. Any value set from Python is overridden by the next fader movement." 211 | ] 212 | }, 213 | { 214 | "cell_type": "markdown", 215 | "metadata": {}, 216 | "source": [ 217 | "## Listening to changes" 218 | ] 219 | }, 220 | { 221 | "cell_type": "markdown", 222 | "metadata": {}, 223 | "source": [ 224 | "As with any widget, we can observe changes from any control to run a function." 225 | ] 226 | }, 227 | { 228 | "cell_type": "code", 229 | "execution_count": null, 230 | "metadata": {}, 231 | "outputs": [], 232 | "source": [ 233 | "from ipywidgets import Output\n", 234 | "\n", 235 | "out = Output()\n", 236 | "\n", 237 | "@out.capture()\n", 238 | "def f(change):\n", 239 | " print('upper left button is %s'%(change.new))\n", 240 | "\n", 241 | "upper_left_button.observe(f, 'value')\n", 242 | "display(out)" 243 | ] 244 | }, 245 | { 246 | "cell_type": "markdown", 247 | "metadata": {}, 248 | "source": [ 249 | "## Linking to other widgets" 250 | ] 251 | }, 252 | { 253 | "cell_type": "markdown", 254 | "metadata": {}, 255 | "source": [ 256 | "You can synchronize these widgets up to other widgets using `link()` to give a nicer GUI. Run the cell below and then try turning the left knob or pressing the upper left button. Also try adjusting the slider and checkbox below to see that the values are synchronized both ways." 257 | ] 258 | }, 259 | { 260 | "cell_type": "code", 261 | "execution_count": null, 262 | "metadata": {}, 263 | "outputs": [], 264 | "source": [ 265 | "from ipywidgets import link, IntSlider, Checkbox, VBox\n", 266 | "slider = IntSlider(description=\"Left knob\", min=left_knob.min, max=left_knob.max)\n", 267 | "checkbox = Checkbox(description=\"Upper left button\")\n", 268 | "\n", 269 | "link((left_knob, 'value'), (slider, 'value'))\n", 270 | "link((upper_left_button, 'value'), (checkbox, 'value'))\n", 271 | "\n", 272 | "display(VBox([slider, checkbox]))" 273 | ] 274 | }, 275 | { 276 | "cell_type": "markdown", 277 | "metadata": {}, 278 | "source": [ 279 | "This package includes a convenience function, `xtouchmini_ux()`, to link each control up to a slider or checkbox widget in a GUI that roughly approximates the physical layout." 280 | ] 281 | }, 282 | { 283 | "cell_type": "code", 284 | "execution_count": null, 285 | "metadata": {}, 286 | "outputs": [], 287 | "source": [ 288 | "xtouchmini_ui(x)" 289 | ] 290 | }, 291 | { 292 | "cell_type": "markdown", 293 | "metadata": {}, 294 | "source": [ 295 | "## Experimenting with options" 296 | ] 297 | }, 298 | { 299 | "cell_type": "markdown", 300 | "metadata": {}, 301 | "source": [ 302 | "Let's set various controls to explore the available button and knob light modes, as well as some random values to see what they look like on the controller." 303 | ] 304 | }, 305 | { 306 | "cell_type": "code", 307 | "execution_count": null, 308 | "metadata": {}, 309 | "outputs": [], 310 | "source": [ 311 | "for b in x.buttons:\n", 312 | " b.mode='toggle'\n", 313 | "for b in x.rotary_buttons[:4]:\n", 314 | " b.mode='toggle'\n", 315 | "for b in x.rotary_buttons[4:]:\n", 316 | " b.mode='momentary'\n", 317 | "for b in x.side_buttons:\n", 318 | " b.mode='momentary'\n", 319 | "for b, mode in zip(x.rotary_encoders, ['single', 'single', 'trim', 'trim', 'wrap', 'wrap', 'spread', 'spread']):\n", 320 | " b.light_mode = mode" 321 | ] 322 | }, 323 | { 324 | "cell_type": "code", 325 | "execution_count": null, 326 | "metadata": {}, 327 | "outputs": [], 328 | "source": [ 329 | "# Set some random values\n", 330 | "import secrets\n", 331 | "for b in x.buttons:\n", 332 | " b.value=secrets.choice([False, True])\n", 333 | "for b in x.rotary_encoders:\n", 334 | " b.value = secrets.randbelow(101)" 335 | ] 336 | }, 337 | { 338 | "cell_type": "markdown", 339 | "metadata": {}, 340 | "source": [ 341 | "## Clearing values" 342 | ] 343 | }, 344 | { 345 | "cell_type": "markdown", 346 | "metadata": {}, 347 | "source": [ 348 | "Finally, let's clear all of the values." 349 | ] 350 | }, 351 | { 352 | "cell_type": "code", 353 | "execution_count": null, 354 | "metadata": {}, 355 | "outputs": [], 356 | "source": [ 357 | "# Clear all values\n", 358 | "for b in x.buttons:\n", 359 | " b.value = False\n", 360 | "for b in x.rotary_buttons:\n", 361 | " b.value = False\n", 362 | "for b in x.rotary_encoders:\n", 363 | " b.value = 0" 364 | ] 365 | }, 366 | { 367 | "cell_type": "code", 368 | "execution_count": null, 369 | "metadata": {}, 370 | "outputs": [], 371 | "source": [] 372 | } 373 | ], 374 | "metadata": { 375 | "kernelspec": { 376 | "display_name": "Python 3", 377 | "language": "python", 378 | "name": "python3" 379 | }, 380 | "language_info": { 381 | "codemirror_mode": { 382 | "name": "ipython", 383 | "version": 3 384 | }, 385 | "file_extension": ".py", 386 | "mimetype": "text/x-python", 387 | "name": "python", 388 | "nbconvert_exporter": "python", 389 | "pygments_lexer": "ipython3", 390 | "version": "3.7.3" 391 | }, 392 | "widgets": { 393 | "application/vnd.jupyter.widget-state+json": { 394 | "state": {}, 395 | "version_major": 2, 396 | "version_minor": 0 397 | } 398 | } 399 | }, 400 | "nbformat": 4, 401 | "nbformat_minor": 4 402 | } 403 | -------------------------------------------------------------------------------- /ipymidicontrols.json: -------------------------------------------------------------------------------- 1 | { 2 | "load_extensions": { 3 | "ipymidicontrols/extension": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ipymidicontrols/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Project Jupyter Contributors. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | from .xtouchmini import XTouchMini, xtouchmini_ui 8 | from ._version import __version__, version_info 9 | 10 | from .nbextension import _jupyter_nbextension_paths 11 | -------------------------------------------------------------------------------- /ipymidicontrols/_frontend.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Project Jupyter Contributors. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | Information about the frontend package of the widgets. 9 | """ 10 | 11 | module_name = "@jupyter-widgets/midicontrols" 12 | module_version = "^0.1.0" 13 | -------------------------------------------------------------------------------- /ipymidicontrols/_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Project Jupyter Contributors. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | from collections import namedtuple 8 | 9 | VersionInfo = namedtuple('VersionInfo', [ 10 | 'major', 11 | 'minor', 12 | 'micro', 13 | 'releaselevel', 14 | 'serial' 15 | ]) 16 | 17 | version_info = VersionInfo(0, 1, 3, 'final', 0) 18 | 19 | _specifier_ = {'alpha': 'a', 'beta': 'b', 'candidate': 'rc', 'final': ''} 20 | 21 | __version__ = '{}.{}.{}{}'.format( 22 | version_info.major, 23 | version_info.minor, 24 | version_info.micro, 25 | ('' 26 | if version_info.releaselevel == 'final' 27 | else _specifier_[version_info.releaselevel] + str(version_info.serial))) 28 | -------------------------------------------------------------------------------- /ipymidicontrols/nbextension/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Project Jupyter Contributors 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | def _jupyter_nbextension_paths(): 8 | return [{ 9 | 'section': 'notebook', 10 | 'src': 'nbextension/static', 11 | 'dest': 'ipymidicontrols', 12 | 'require': '@jupyter-widgets/midicontrols/extension' 13 | }] 14 | -------------------------------------------------------------------------------- /ipymidicontrols/nbextension/static/extension.js: -------------------------------------------------------------------------------- 1 | // Entry point for the notebook bundle containing custom model definitions. 2 | // 3 | define(function() { 4 | "use strict"; 5 | 6 | window['requirejs'].config({ 7 | map: { 8 | '*': { 9 | '@jupyter-widgets/midicontrols': 'nbextensions/ipymidicontrols/index', 10 | }, 11 | } 12 | }); 13 | // Export the required load_ipython_extension function 14 | return { 15 | load_ipython_extension : function() {} 16 | }; 17 | }); 18 | -------------------------------------------------------------------------------- /ipymidicontrols/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter-widgets/midicontrols/54e1cc31842984c7e4b2e9b52960abb7546d25f8/ipymidicontrols/tests/__init__.py -------------------------------------------------------------------------------- /ipymidicontrols/tests/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Project Jupyter Contributors. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | import pytest 8 | 9 | from ipykernel.comm import Comm 10 | from ipywidgets import Widget 11 | 12 | class MockComm(Comm): 13 | """A mock Comm object. 14 | 15 | Can be used to inspect calls to Comm's open/send/close methods. 16 | """ 17 | comm_id = 'a-b-c-d' 18 | kernel = 'Truthy' 19 | 20 | def __init__(self, *args, **kwargs): 21 | self.log_open = [] 22 | self.log_send = [] 23 | self.log_close = [] 24 | super(MockComm, self).__init__(*args, **kwargs) 25 | 26 | def open(self, *args, **kwargs): 27 | self.log_open.append((args, kwargs)) 28 | 29 | def send(self, *args, **kwargs): 30 | self.log_send.append((args, kwargs)) 31 | 32 | def close(self, *args, **kwargs): 33 | self.log_close.append((args, kwargs)) 34 | 35 | _widget_attrs = {} 36 | undefined = object() 37 | 38 | 39 | @pytest.fixture 40 | def mock_comm(): 41 | _widget_attrs['_comm_default'] = getattr(Widget, '_comm_default', undefined) 42 | Widget._comm_default = lambda self: MockComm() 43 | _widget_attrs['_ipython_display_'] = Widget._ipython_display_ 44 | def raise_not_implemented(*args, **kwargs): 45 | raise NotImplementedError() 46 | Widget._ipython_display_ = raise_not_implemented 47 | 48 | yield MockComm() 49 | 50 | for attr, value in _widget_attrs.items(): 51 | if value is undefined: 52 | delattr(Widget, attr) 53 | else: 54 | setattr(Widget, attr, value) 55 | -------------------------------------------------------------------------------- /ipymidicontrols/tests/test_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Project Jupyter Contributors. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | import pytest 8 | 9 | from ..example import ExampleWidget 10 | 11 | 12 | def test_example_creation_blank(): 13 | w = ExampleWidget() 14 | assert w.value == 'Hello World' 15 | -------------------------------------------------------------------------------- /ipymidicontrols/tests/test_nbextension_path.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Project Jupyter Contributors. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | 8 | def test_nbextension_path(): 9 | # Check that magic function can be imported from package root: 10 | from ipymidicontrols import _jupyter_nbextension_paths 11 | # Ensure that it can be called without incident: 12 | path = _jupyter_nbextension_paths() 13 | # Some sanity checks: 14 | assert len(path) == 1 15 | assert isinstance(path[0], dict) 16 | -------------------------------------------------------------------------------- /ipymidicontrols/xtouchmini.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Project Jupyter Contributors. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | Widgets for the X-Touch Mini midi controller 9 | """ 10 | 11 | from ipywidgets import DOMWidget, widget_serialization, register, trait_types 12 | from traitlets import Unicode, CaselessStrEnum, Bool, CInt, validate, Instance, TraitError 13 | from ._frontend import module_name, module_version 14 | 15 | @register 16 | class Button(DOMWidget): 17 | """A widget representing a button on a MIDI controller. 18 | """ 19 | _model_name = Unicode('ButtonModel').tag(sync=True) 20 | _model_module = Unicode(module_name).tag(sync=True) 21 | _model_module_version = Unicode(module_version).tag(sync=True) 22 | _view_name = Unicode('ValueView').tag(sync=True) 23 | _view_module = Unicode(module_name).tag(sync=True) 24 | _view_module_version = Unicode(module_version).tag(sync=True) 25 | 26 | value = Bool(False, help="Bool value").tag(sync=True) 27 | mode = CaselessStrEnum( 28 | values=['momentary', 'toggle'], default_value='momentary', 29 | help="""How the button changes value.""").tag(sync=True) 30 | 31 | 32 | class _IntRange(DOMWidget): 33 | """A widget representing an integer range on a MIDI controller. 34 | """ 35 | _model_name = Unicode('').tag(sync=True) 36 | _model_module = Unicode(module_name).tag(sync=True) 37 | _model_module_version = Unicode(module_version).tag(sync=True) 38 | _view_name = Unicode('ValueView').tag(sync=True) 39 | _view_module = Unicode(module_name).tag(sync=True) 40 | _view_module_version = Unicode(module_version).tag(sync=True) 41 | 42 | value = CInt(0, help="Int value").tag(sync=True) 43 | max = CInt(100, help="Max value").tag(sync=True) 44 | min = CInt(0, help="Min value").tag(sync=True) 45 | 46 | def __init__(self, value=None, min=None, max=None, **kwargs): 47 | if value is not None: 48 | kwargs['value'] = value 49 | if min is not None: 50 | kwargs['min'] = min 51 | if max is not None: 52 | kwargs['max'] = max 53 | super(DOMWidget, self).__init__(**kwargs) 54 | 55 | @validate('value') 56 | def _validate_value(self, proposal): 57 | """Cap and floor value""" 58 | value = proposal['value'] 59 | if self.min > value or self.max < value: 60 | value = min(max(value, self.min), self.max) 61 | return value 62 | 63 | @validate('min') 64 | def _validate_min(self, proposal): 65 | """Enforce min <= value <= max""" 66 | min = proposal['value'] 67 | if min > self.max: 68 | raise TraitError('setting min > max') 69 | if min > self.value: 70 | self.value = min 71 | return min 72 | 73 | @validate('max') 74 | def _validate_max(self, proposal): 75 | """Enforce min <= value <= max""" 76 | max = proposal['value'] 77 | if max < self.min: 78 | raise TraitError('setting max < min') 79 | if max < self.value: 80 | self.value = max 81 | return max 82 | 83 | @register 84 | class Rotary(_IntRange): 85 | """A widget representing a rotary knob on a MIDI controller. 86 | """ 87 | _model_name = Unicode('RotaryModel').tag(sync=True) 88 | 89 | light_mode = CaselessStrEnum( 90 | values=['single', 'trim', 'wrap', 'spread'], default_value='single', 91 | help="""How the lights around the rotary dial indicate the value.""").tag(sync=True) 92 | 93 | @register 94 | class Fader(_IntRange): 95 | """A widget representing a fader on a MIDI controller. 96 | """ 97 | _model_name = Unicode('FaderModel').tag(sync=True) 98 | max = CInt(127, help="Max value").tag(sync=True) 99 | 100 | 101 | @register 102 | class XTouchMini(DOMWidget): 103 | _model_name = Unicode('XTouchMiniModel').tag(sync=True) 104 | _model_module = Unicode(module_name).tag(sync=True) 105 | _model_module_version = Unicode(module_version).tag(sync=True) 106 | _view_name = Unicode('XTouchMiniView').tag(sync=True) 107 | _view_module = Unicode(module_name).tag(sync=True) 108 | _view_module_version = Unicode(module_version).tag(sync=True) 109 | 110 | # Child widgets in the container. 111 | # Using a tuple here to force reassignment to update the list. 112 | # When a proper notifying-list trait exists, use that instead. 113 | buttons = trait_types.TypedTuple(trait=Instance(Button), help="Buttons in main area, top row left to right, then bottom row left to right").tag( 114 | sync=True, **widget_serialization) 115 | 116 | side_buttons = trait_types.TypedTuple(trait=Instance(Button), help="Buttons on right side, top to bottom").tag( 117 | sync=True, **widget_serialization) 118 | 119 | rotary_buttons = trait_types.TypedTuple(trait=Instance(Button), help="Rotary buttons left to right").tag( 120 | sync=True, **widget_serialization) 121 | 122 | rotary_encoders = trait_types.TypedTuple(trait=Instance(Rotary), help="Rotary encoders left to right").tag( 123 | sync=True, **widget_serialization) 124 | 125 | faders = trait_types.TypedTuple(trait=Instance(Fader), help="Fader").tag( 126 | sync=True, **widget_serialization) 127 | 128 | 129 | from ipywidgets import IntSlider, Checkbox, HBox, VBox, link, Layout 130 | 131 | def xtouchmini_ui(x): 132 | layout = Layout(width='24px') 133 | knobs = [IntSlider(orientation='vertical', layout=layout) for i in x.rotary_encoders] 134 | for i,j in zip(x.rotary_encoders, knobs): 135 | link((i, 'value'), (j, 'value')) 136 | 137 | knob_buttons = [Checkbox(indent=False, layout=layout) for i in x.rotary_buttons] 138 | for i,j in zip(x.rotary_buttons, knob_buttons): 139 | link((i, 'value'), (j, 'value')) 140 | 141 | buttons = [Checkbox(indent=False, layout=layout) for i in x.buttons] 142 | for i,j in zip(x.buttons, buttons): 143 | link((i, 'value'), (j, 'value')) 144 | 145 | side_buttons = [Checkbox(indent=False, layout=layout) for i in x.side_buttons] 146 | for i,j in zip(x.side_buttons, side_buttons): 147 | link((i, 'value'), (j, 'value')) 148 | 149 | fader = IntSlider(orientation='vertical', max=127, layout={'height': 'inherit'}) 150 | link((fader, 'value'), (x.faders[0], 'value')) 151 | 152 | ui = VBox([ 153 | HBox([HBox([VBox([j,i]) for i,j in zip(knobs, knob_buttons)]), fader]), 154 | HBox([ 155 | VBox([ 156 | HBox(buttons[:8]), 157 | HBox(buttons[8:]) 158 | ]), 159 | VBox(side_buttons, layout={'align_items': 'flex-end', 'width': '55px'}) 160 | ]) 161 | ]) 162 | return ui -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jupyter-widgets/midicontrols", 3 | "version": "0.1.2", 4 | "description": "A Jupyter widget for interfacing with MIDI controllers.", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension", 9 | "widgets" 10 | ], 11 | "files": [ 12 | "lib/**/*.js", 13 | "lib/*.d.ts", 14 | "lib/*.js.map", 15 | "dist/*.js", 16 | "dist/*.js.map" 17 | ], 18 | "homepage": "https://github.com/jupyter-widgets/midicontrols", 19 | "bugs": { 20 | "url": "https://github.com/jupyter-widgets/midicontrols/issues" 21 | }, 22 | "license": "BSD-3-Clause", 23 | "author": { 24 | "name": "Project Jupyter Contributors", 25 | "email": "jupyter@googlegroups.com" 26 | }, 27 | "main": "lib/index.js", 28 | "types": "./lib/index.d.ts", 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/jupyter-widgets/midicontrols" 32 | }, 33 | "scripts": { 34 | "build": "npm run build:lib && npm run build:nbextension", 35 | "build:labextension": "npm run clean:labextension && mkdirp ipymidicontrols/labextension && cd ipymidicontrols/labextension && npm pack ../..", 36 | "build:lib": "tsc", 37 | "build:nbextension": "webpack -p", 38 | "build:all": "npm run build:labextension && npm run build:nbextension", 39 | "clean": "npm run clean:lib && npm run clean:nbextension && rimraf tsconfig.tsbuildinfo", 40 | "clean:lib": "rimraf lib", 41 | "clean:labextension": "rimraf ipymidicontrols/labextension", 42 | "clean:nbextension": "rimraf ipymidicontrols/nbextension/static/index.js", 43 | "prepack": "npm run build:lib", 44 | "test": "npm run test:firefox", 45 | "test:chrome": "karma start --browsers=Chrome tests/karma.conf.js", 46 | "test:debug": "karma start --browsers=Chrome --singleRun=false --debug=true tests/karma.conf.js", 47 | "test:firefox": "karma start --browsers=Firefox tests/karma.conf.js", 48 | "test:ie": "karma start --browsers=IE tests/karma.conf.js", 49 | "watch": "npm-run-all -p watch:*", 50 | "watch:lib": "tsc -w", 51 | "watch:nbextension": "webpack --watch" 52 | }, 53 | "dependencies": { 54 | "@jupyter-widgets/base": "^1.1.10 || ^2", 55 | "@phosphor/signaling": "^1.2.2", 56 | "webmidi": "^2.5.1" 57 | }, 58 | "devDependencies": { 59 | "@phosphor/application": "^1.6.0", 60 | "@phosphor/widgets": "^1.6.0", 61 | "@types/expect.js": "^0.3.29", 62 | "@types/mocha": "^5.2.5", 63 | "@types/node": "^10.11.6", 64 | "@types/webpack-env": "^1.13.6", 65 | "expect.js": "^0.3.1", 66 | "fs-extra": "^7.0.0", 67 | "karma": "^3.0.0", 68 | "karma-chrome-launcher": "^2.2.0", 69 | "karma-firefox-launcher": "^1.1.0", 70 | "karma-ie-launcher": "^1.0.0", 71 | "karma-mocha": "^1.3.0", 72 | "karma-mocha-reporter": "^2.2.5", 73 | "karma-typescript": "^3.0.13", 74 | "mkdirp": "^0.5.1", 75 | "mocha": "^5.2.0", 76 | "npm-run-all": "^4.1.3", 77 | "rimraf": "^2.6.2", 78 | "source-map-loader": "^0.2.4", 79 | "ts-loader": "^6", 80 | "typescript": "~3.5.3", 81 | "webpack": "^4.20.2", 82 | "webpack-cli": "^3.1.2", 83 | "yarn": "^1.16" 84 | }, 85 | "jupyterlab": { 86 | "extension": "lib/plugin" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = ipymidicontrols/tests examples 3 | norecursedirs = node_modules .ipynb_checkpoints 4 | addopts = --nbval --current-env 5 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | type: sphinx 2 | python: 3 | version: 3 4 | pip_install: true 5 | extra_requirements: 6 | - examples 7 | - docs 8 | conda: 9 | file: docs/environment.yml 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [metadata] 5 | description-file = README.md 6 | license_file = LICENSE.txt 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | from __future__ import print_function 8 | from glob import glob 9 | from os.path import join as pjoin 10 | 11 | 12 | from setupbase import ( 13 | create_cmdclass, install_npm, ensure_targets, 14 | find_packages, combine_commands, ensure_python, 15 | get_version, HERE 16 | ) 17 | 18 | from setuptools import setup 19 | 20 | 21 | # The name of the project 22 | name = 'ipymidicontrols' 23 | 24 | # Ensure a valid python version 25 | ensure_python('>=3.4') 26 | 27 | # Get our version 28 | version = get_version(pjoin(name, '_version.py')) 29 | 30 | nb_path = pjoin(HERE, name, 'nbextension', 'static') 31 | lab_path = pjoin(HERE, name, 'labextension') 32 | 33 | # Representative files that should exist after a successful build 34 | jstargets = [ 35 | pjoin(nb_path, 'index.js'), 36 | pjoin(HERE, 'lib', 'plugin.js'), 37 | ] 38 | 39 | package_data_spec = { 40 | name: [ 41 | 'nbextension/static/*.*js*', 42 | 'labextension/*.tgz' 43 | ] 44 | } 45 | 46 | data_files_spec = [ 47 | ('share/jupyter/nbextensions/ipymidicontrols', 48 | nb_path, '*.js*'), 49 | ('share/jupyter/lab/extensions', lab_path, '*.tgz'), 50 | ('etc/jupyter/nbconfig/notebook.d' , HERE, 'ipymidicontrols.json') 51 | ] 52 | 53 | 54 | cmdclass = create_cmdclass('jsdeps', package_data_spec=package_data_spec, 55 | data_files_spec=data_files_spec) 56 | cmdclass['jsdeps'] = combine_commands( 57 | install_npm(HERE, build_cmd='build:all'), 58 | ensure_targets(jstargets), 59 | ) 60 | 61 | 62 | setup_args = dict( 63 | name = name, 64 | description = 'A Jupyter widget for interfacing with MIDI controllers.', 65 | version = version, 66 | scripts = glob(pjoin('scripts', '*')), 67 | cmdclass = cmdclass, 68 | packages = find_packages(), 69 | author = 'Project Jupyter Contributors', 70 | author_email = 'jupyter@googlegroups.com', 71 | url = 'https://github.com/jupyter-widgets/midicontrols', 72 | license = 'BSD', 73 | platforms = "Linux, Mac OS X, Windows", 74 | keywords = ['Jupyter', 'Widgets', 'IPython'], 75 | classifiers = [ 76 | 'Intended Audience :: Developers', 77 | 'Intended Audience :: Science/Research', 78 | 'License :: OSI Approved :: BSD License', 79 | 'Programming Language :: Python', 80 | 'Programming Language :: Python :: 3', 81 | 'Programming Language :: Python :: 3.4', 82 | 'Programming Language :: Python :: 3.5', 83 | 'Programming Language :: Python :: 3.6', 84 | 'Programming Language :: Python :: 3.7', 85 | 'Framework :: Jupyter', 86 | ], 87 | include_package_data = True, 88 | install_requires = [ 89 | 'ipywidgets>=7.0.0', 90 | ], 91 | extras_require = { 92 | 'test': [ 93 | 'pytest', 94 | 'pytest-cov', 95 | 'nbval', 96 | ], 97 | 'examples': [ 98 | # Any requirements for the examples to run 99 | ], 100 | 'docs': [ 101 | 'sphinx>=1.5', 102 | 'recommonmark', 103 | 'sphinx_rtd_theme', 104 | 'nbsphinx>=0.2.13,<0.4.0', 105 | 'nbsphinx-link', 106 | 'pytest_check_links', 107 | 'pypandoc', 108 | ], 109 | }, 110 | entry_points = { 111 | }, 112 | ) 113 | 114 | if __name__ == '__main__': 115 | setup(**setup_args) 116 | -------------------------------------------------------------------------------- /setupbase.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | """ 8 | This file originates from the 'jupyter-packaging' package, and 9 | contains a set of useful utilities for including npm packages 10 | within a Python package. 11 | """ 12 | from collections import defaultdict 13 | from os.path import join as pjoin 14 | import io 15 | import os 16 | import functools 17 | import pipes 18 | import re 19 | import shlex 20 | import subprocess 21 | import sys 22 | 23 | 24 | # BEFORE importing distutils, remove MANIFEST. distutils doesn't properly 25 | # update it when the contents of directories change. 26 | if os.path.exists('MANIFEST'): os.remove('MANIFEST') 27 | 28 | 29 | from distutils.cmd import Command 30 | from distutils.command.build_py import build_py 31 | from distutils.command.sdist import sdist 32 | from distutils import log 33 | 34 | from setuptools.command.develop import develop 35 | from setuptools.command.bdist_egg import bdist_egg 36 | 37 | try: 38 | from wheel.bdist_wheel import bdist_wheel 39 | except ImportError: 40 | bdist_wheel = None 41 | 42 | if sys.platform == 'win32': 43 | from subprocess import list2cmdline 44 | else: 45 | def list2cmdline(cmd_list): 46 | return ' '.join(map(pipes.quote, cmd_list)) 47 | 48 | 49 | __version__ = '0.2.0' 50 | 51 | # --------------------------------------------------------------------------- 52 | # Top Level Variables 53 | # --------------------------------------------------------------------------- 54 | 55 | HERE = os.path.abspath(os.path.dirname(__file__)) 56 | is_repo = os.path.exists(pjoin(HERE, '.git')) 57 | node_modules = pjoin(HERE, 'node_modules') 58 | 59 | SEPARATORS = os.sep if os.altsep is None else os.sep + os.altsep 60 | 61 | npm_path = ':'.join([ 62 | pjoin(HERE, 'node_modules', '.bin'), 63 | os.environ.get('PATH', os.defpath), 64 | ]) 65 | 66 | if "--skip-npm" in sys.argv: 67 | print("Skipping npm install as requested.") 68 | skip_npm = True 69 | sys.argv.remove("--skip-npm") 70 | else: 71 | skip_npm = False 72 | 73 | 74 | # --------------------------------------------------------------------------- 75 | # Public Functions 76 | # --------------------------------------------------------------------------- 77 | 78 | def get_version(file, name='__version__'): 79 | """Get the version of the package from the given file by 80 | executing it and extracting the given `name`. 81 | """ 82 | path = os.path.realpath(file) 83 | version_ns = {} 84 | with io.open(path, encoding="utf8") as f: 85 | exec(f.read(), {}, version_ns) 86 | return version_ns[name] 87 | 88 | 89 | def ensure_python(specs): 90 | """Given a list of range specifiers for python, ensure compatibility. 91 | """ 92 | if not isinstance(specs, (list, tuple)): 93 | specs = [specs] 94 | v = sys.version_info 95 | part = '%s.%s' % (v.major, v.minor) 96 | for spec in specs: 97 | if part == spec: 98 | return 99 | try: 100 | if eval(part + spec): 101 | return 102 | except SyntaxError: 103 | pass 104 | raise ValueError('Python version %s unsupported' % part) 105 | 106 | 107 | def find_packages(top=HERE): 108 | """ 109 | Find all of the packages. 110 | """ 111 | packages = [] 112 | for d, dirs, _ in os.walk(top, followlinks=True): 113 | if os.path.exists(pjoin(d, '__init__.py')): 114 | packages.append(os.path.relpath(d, top).replace(os.path.sep, '.')) 115 | elif d != top: 116 | # Do not look for packages in subfolders if current is not a package 117 | dirs[:] = [] 118 | return packages 119 | 120 | 121 | def update_package_data(distribution): 122 | """update build_py options to get package_data changes""" 123 | build_py = distribution.get_command_obj('build_py') 124 | build_py.finalize_options() 125 | 126 | 127 | class bdist_egg_disabled(bdist_egg): 128 | """Disabled version of bdist_egg 129 | 130 | Prevents setup.py install performing setuptools' default easy_install, 131 | which it should never ever do. 132 | """ 133 | def run(self): 134 | sys.exit("Aborting implicit building of eggs. Use `pip install .` " 135 | " to install from source.") 136 | 137 | 138 | def create_cmdclass(prerelease_cmd=None, package_data_spec=None, 139 | data_files_spec=None): 140 | """Create a command class with the given optional prerelease class. 141 | 142 | Parameters 143 | ---------- 144 | prerelease_cmd: (name, Command) tuple, optional 145 | The command to run before releasing. 146 | package_data_spec: dict, optional 147 | A dictionary whose keys are the dotted package names and 148 | whose values are a list of glob patterns. 149 | data_files_spec: list, optional 150 | A list of (path, dname, pattern) tuples where the path is the 151 | `data_files` install path, dname is the source directory, and the 152 | pattern is a glob pattern. 153 | 154 | Notes 155 | ----- 156 | We use specs so that we can find the files *after* the build 157 | command has run. 158 | 159 | The package data glob patterns should be relative paths from the package 160 | folder containing the __init__.py file, which is given as the package 161 | name. 162 | e.g. `dict(foo=['./bar/*', './baz/**'])` 163 | 164 | The data files directories should be absolute paths or relative paths 165 | from the root directory of the repository. Data files are specified 166 | differently from `package_data` because we need a separate path entry 167 | for each nested folder in `data_files`, and this makes it easier to 168 | parse. 169 | e.g. `('share/foo/bar', 'pkgname/bizz, '*')` 170 | """ 171 | wrapped = [prerelease_cmd] if prerelease_cmd else [] 172 | if package_data_spec or data_files_spec: 173 | wrapped.append('handle_files') 174 | wrapper = functools.partial(_wrap_command, wrapped) 175 | handle_files = _get_file_handler(package_data_spec, data_files_spec) 176 | 177 | if 'bdist_egg' in sys.argv: 178 | egg = wrapper(bdist_egg, strict=True) 179 | else: 180 | egg = bdist_egg_disabled 181 | 182 | cmdclass = dict( 183 | build_py=wrapper(build_py, strict=is_repo), 184 | bdist_egg=egg, 185 | sdist=wrapper(sdist, strict=True), 186 | handle_files=handle_files, 187 | ) 188 | 189 | if bdist_wheel: 190 | cmdclass['bdist_wheel'] = wrapper(bdist_wheel, strict=True) 191 | 192 | cmdclass['develop'] = wrapper(develop, strict=True) 193 | return cmdclass 194 | 195 | 196 | def command_for_func(func): 197 | """Create a command that calls the given function.""" 198 | 199 | class FuncCommand(BaseCommand): 200 | 201 | def run(self): 202 | func() 203 | update_package_data(self.distribution) 204 | 205 | return FuncCommand 206 | 207 | 208 | def run(cmd, **kwargs): 209 | """Echo a command before running it. Defaults to repo as cwd""" 210 | log.info('> ' + list2cmdline(cmd)) 211 | kwargs.setdefault('cwd', HERE) 212 | kwargs.setdefault('shell', os.name == 'nt') 213 | if not isinstance(cmd, (list, tuple)) and os.name != 'nt': 214 | cmd = shlex.split(cmd) 215 | cmd_path = which(cmd[0]) 216 | if not cmd_path: 217 | sys.exit("Aborting. Could not find cmd (%s) in path. " 218 | "If command is not expected to be in user's path, " 219 | "use an absolute path." % cmd[0]) 220 | cmd[0] = cmd_path 221 | return subprocess.check_call(cmd, **kwargs) 222 | 223 | 224 | def is_stale(target, source): 225 | """Test whether the target file/directory is stale based on the source 226 | file/directory. 227 | """ 228 | if not os.path.exists(target): 229 | return True 230 | target_mtime = recursive_mtime(target) or 0 231 | return compare_recursive_mtime(source, cutoff=target_mtime) 232 | 233 | 234 | class BaseCommand(Command): 235 | """Empty command because Command needs subclasses to override too much""" 236 | user_options = [] 237 | 238 | def initialize_options(self): 239 | pass 240 | 241 | def finalize_options(self): 242 | pass 243 | 244 | def get_inputs(self): 245 | return [] 246 | 247 | def get_outputs(self): 248 | return [] 249 | 250 | 251 | def combine_commands(*commands): 252 | """Return a Command that combines several commands.""" 253 | 254 | class CombinedCommand(Command): 255 | user_options = [] 256 | 257 | def initialize_options(self): 258 | self.commands = [] 259 | for C in commands: 260 | self.commands.append(C(self.distribution)) 261 | for c in self.commands: 262 | c.initialize_options() 263 | 264 | def finalize_options(self): 265 | for c in self.commands: 266 | c.finalize_options() 267 | 268 | def run(self): 269 | for c in self.commands: 270 | c.run() 271 | return CombinedCommand 272 | 273 | 274 | def compare_recursive_mtime(path, cutoff, newest=True): 275 | """Compare the newest/oldest mtime for all files in a directory. 276 | 277 | Cutoff should be another mtime to be compared against. If an mtime that is 278 | newer/older than the cutoff is found it will return True. 279 | E.g. if newest=True, and a file in path is newer than the cutoff, it will 280 | return True. 281 | """ 282 | if os.path.isfile(path): 283 | mt = mtime(path) 284 | if newest: 285 | if mt > cutoff: 286 | return True 287 | elif mt < cutoff: 288 | return True 289 | for dirname, _, filenames in os.walk(path, topdown=False): 290 | for filename in filenames: 291 | mt = mtime(pjoin(dirname, filename)) 292 | if newest: # Put outside of loop? 293 | if mt > cutoff: 294 | return True 295 | elif mt < cutoff: 296 | return True 297 | return False 298 | 299 | 300 | def recursive_mtime(path, newest=True): 301 | """Gets the newest/oldest mtime for all files in a directory.""" 302 | if os.path.isfile(path): 303 | return mtime(path) 304 | current_extreme = None 305 | for dirname, dirnames, filenames in os.walk(path, topdown=False): 306 | for filename in filenames: 307 | mt = mtime(pjoin(dirname, filename)) 308 | if newest: # Put outside of loop? 309 | if mt >= (current_extreme or mt): 310 | current_extreme = mt 311 | elif mt <= (current_extreme or mt): 312 | current_extreme = mt 313 | return current_extreme 314 | 315 | 316 | def mtime(path): 317 | """shorthand for mtime""" 318 | return os.stat(path).st_mtime 319 | 320 | 321 | def install_npm(path=None, build_dir=None, source_dir=None, build_cmd='build', force=False, npm=None): 322 | """Return a Command for managing an npm installation. 323 | 324 | Note: The command is skipped if the `--skip-npm` flag is used. 325 | 326 | Parameters 327 | ---------- 328 | path: str, optional 329 | The base path of the node package. Defaults to the repo root. 330 | build_dir: str, optional 331 | The target build directory. If this and source_dir are given, 332 | the JavaScript will only be build if necessary. 333 | source_dir: str, optional 334 | The source code directory. 335 | build_cmd: str, optional 336 | The npm command to build assets to the build_dir. 337 | npm: str or list, optional. 338 | The npm executable name, or a tuple of ['node', executable]. 339 | """ 340 | 341 | class NPM(BaseCommand): 342 | description = 'install package.json dependencies using npm' 343 | 344 | def run(self): 345 | if skip_npm: 346 | log.info('Skipping npm-installation') 347 | return 348 | node_package = path or HERE 349 | node_modules = pjoin(node_package, 'node_modules') 350 | is_yarn = os.path.exists(pjoin(node_package, 'yarn.lock')) 351 | 352 | npm_cmd = npm 353 | 354 | if npm is None: 355 | if is_yarn: 356 | npm_cmd = ['yarn'] 357 | else: 358 | npm_cmd = ['npm'] 359 | 360 | if not which(npm_cmd[0]): 361 | log.error("`{0}` unavailable. If you're running this command " 362 | "using sudo, make sure `{0}` is availble to sudo" 363 | .format(npm_cmd[0])) 364 | return 365 | 366 | if force or is_stale(node_modules, pjoin(node_package, 'package.json')): 367 | log.info('Installing build dependencies with npm. This may ' 368 | 'take a while...') 369 | run(npm_cmd + ['install'], cwd=node_package) 370 | if build_dir and source_dir and not force: 371 | should_build = is_stale(build_dir, source_dir) 372 | else: 373 | should_build = True 374 | if should_build: 375 | run(npm_cmd + ['run', build_cmd], cwd=node_package) 376 | 377 | return NPM 378 | 379 | 380 | def ensure_targets(targets): 381 | """Return a Command that checks that certain files exist. 382 | 383 | Raises a ValueError if any of the files are missing. 384 | 385 | Note: The check is skipped if the `--skip-npm` flag is used. 386 | """ 387 | 388 | class TargetsCheck(BaseCommand): 389 | def run(self): 390 | if skip_npm: 391 | log.info('Skipping target checks') 392 | return 393 | missing = [t for t in targets if not os.path.exists(t)] 394 | if missing: 395 | raise ValueError(('missing files: %s' % missing)) 396 | 397 | return TargetsCheck 398 | 399 | 400 | # `shutils.which` function copied verbatim from the Python-3.3 source. 401 | def which(cmd, mode=os.F_OK | os.X_OK, path=None): 402 | """Given a command, mode, and a PATH string, return the path which 403 | conforms to the given mode on the PATH, or None if there is no such 404 | file. 405 | `mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result 406 | of os.environ.get("PATH"), or can be overridden with a custom search 407 | path. 408 | """ 409 | 410 | # Check that a given file can be accessed with the correct mode. 411 | # Additionally check that `file` is not a directory, as on Windows 412 | # directories pass the os.access check. 413 | def _access_check(fn, mode): 414 | return (os.path.exists(fn) and os.access(fn, mode) and 415 | not os.path.isdir(fn)) 416 | 417 | # Short circuit. If we're given a full path which matches the mode 418 | # and it exists, we're done here. 419 | if _access_check(cmd, mode): 420 | return cmd 421 | 422 | path = (path or os.environ.get("PATH", os.defpath)).split(os.pathsep) 423 | 424 | if sys.platform == "win32": 425 | # The current directory takes precedence on Windows. 426 | if os.curdir not in path: 427 | path.insert(0, os.curdir) 428 | 429 | # PATHEXT is necessary to check on Windows. 430 | pathext = os.environ.get("PATHEXT", "").split(os.pathsep) 431 | # See if the given file matches any of the expected path extensions. 432 | # This will allow us to short circuit when given "python.exe". 433 | matches = [cmd for ext in pathext if cmd.lower().endswith(ext.lower())] 434 | # If it does match, only test that one, otherwise we have to try 435 | # others. 436 | files = [cmd] if matches else [cmd + ext.lower() for ext in pathext] 437 | else: 438 | # On other platforms you don't have things like PATHEXT to tell you 439 | # what file suffixes are executable, so just pass on cmd as-is. 440 | files = [cmd] 441 | 442 | seen = set() 443 | for dir in path: 444 | dir = os.path.normcase(dir) 445 | if dir not in seen: 446 | seen.add(dir) 447 | for thefile in files: 448 | name = os.path.join(dir, thefile) 449 | if _access_check(name, mode): 450 | return name 451 | return None 452 | 453 | 454 | # --------------------------------------------------------------------------- 455 | # Private Functions 456 | # --------------------------------------------------------------------------- 457 | 458 | 459 | def _wrap_command(cmds, cls, strict=True): 460 | """Wrap a setup command 461 | 462 | Parameters 463 | ---------- 464 | cmds: list(str) 465 | The names of the other commands to run prior to the command. 466 | strict: boolean, optional 467 | Wether to raise errors when a pre-command fails. 468 | """ 469 | class WrappedCommand(cls): 470 | 471 | def run(self): 472 | if not getattr(self, 'uninstall', None): 473 | try: 474 | [self.run_command(cmd) for cmd in cmds] 475 | except Exception: 476 | if strict: 477 | raise 478 | else: 479 | pass 480 | # update package data 481 | update_package_data(self.distribution) 482 | 483 | result = cls.run(self) 484 | return result 485 | return WrappedCommand 486 | 487 | 488 | def _get_file_handler(package_data_spec, data_files_spec): 489 | """Get a package_data and data_files handler command. 490 | """ 491 | class FileHandler(BaseCommand): 492 | 493 | def run(self): 494 | package_data = self.distribution.package_data 495 | package_spec = package_data_spec or dict() 496 | 497 | for (key, patterns) in package_spec.items(): 498 | package_data[key] = _get_package_data(key, patterns) 499 | 500 | self.distribution.data_files = _get_data_files( 501 | data_files_spec, self.distribution.data_files 502 | ) 503 | 504 | return FileHandler 505 | 506 | 507 | def _glob_pjoin(*parts): 508 | """Join paths for glob processing""" 509 | if parts[0] in ('.', ''): 510 | parts = parts[1:] 511 | return pjoin(*parts).replace(os.sep, '/') 512 | 513 | 514 | def _get_data_files(data_specs, existing, top=HERE): 515 | """Expand data file specs into valid data files metadata. 516 | 517 | Parameters 518 | ---------- 519 | data_specs: list of tuples 520 | See [create_cmdclass] for description. 521 | existing: list of tuples 522 | The existing distrubution data_files metadata. 523 | 524 | Returns 525 | ------- 526 | A valid list of data_files items. 527 | """ 528 | # Extract the existing data files into a staging object. 529 | file_data = defaultdict(list) 530 | for (path, files) in existing or []: 531 | file_data[path] = files 532 | 533 | # Extract the files and assign them to the proper data 534 | # files path. 535 | for (path, dname, pattern) in data_specs or []: 536 | if os.path.isabs(dname): 537 | dname = os.path.relpath(dname, top) 538 | dname = dname.replace(os.sep, '/') 539 | offset = 0 if dname in ('.', '') else len(dname) + 1 540 | files = _get_files(_glob_pjoin(dname, pattern), top=top) 541 | for fname in files: 542 | # Normalize the path. 543 | root = os.path.dirname(fname) 544 | full_path = _glob_pjoin(path, root[offset:]) 545 | print(dname, root, full_path, offset) 546 | if full_path.endswith('/'): 547 | full_path = full_path[:-1] 548 | file_data[full_path].append(fname) 549 | 550 | # Construct the data files spec. 551 | data_files = [] 552 | for (path, files) in file_data.items(): 553 | data_files.append((path, files)) 554 | return data_files 555 | 556 | 557 | def _get_files(file_patterns, top=HERE): 558 | """Expand file patterns to a list of paths. 559 | 560 | Parameters 561 | ----------- 562 | file_patterns: list or str 563 | A list of glob patterns for the data file locations. 564 | The globs can be recursive if they include a `**`. 565 | They should be relative paths from the top directory or 566 | absolute paths. 567 | top: str 568 | the directory to consider for data files 569 | 570 | Note: 571 | Files in `node_modules` are ignored. 572 | """ 573 | if not isinstance(file_patterns, (list, tuple)): 574 | file_patterns = [file_patterns] 575 | 576 | for i, p in enumerate(file_patterns): 577 | if os.path.isabs(p): 578 | file_patterns[i] = os.path.relpath(p, top) 579 | 580 | matchers = [_compile_pattern(p) for p in file_patterns] 581 | 582 | files = set() 583 | 584 | for root, dirnames, filenames in os.walk(top): 585 | # Don't recurse into node_modules 586 | if 'node_modules' in dirnames: 587 | dirnames.remove('node_modules') 588 | for m in matchers: 589 | for filename in filenames: 590 | fn = os.path.relpath(_glob_pjoin(root, filename), top) 591 | fn = fn.replace(os.sep, '/') 592 | if m(fn): 593 | files.add(fn.replace(os.sep, '/')) 594 | 595 | return list(files) 596 | 597 | 598 | def _get_package_data(root, file_patterns=None): 599 | """Expand file patterns to a list of `package_data` paths. 600 | 601 | Parameters 602 | ----------- 603 | root: str 604 | The relative path to the package root from `HERE`. 605 | file_patterns: list or str, optional 606 | A list of glob patterns for the data file locations. 607 | The globs can be recursive if they include a `**`. 608 | They should be relative paths from the root or 609 | absolute paths. If not given, all files will be used. 610 | 611 | Note: 612 | Files in `node_modules` are ignored. 613 | """ 614 | if file_patterns is None: 615 | file_patterns = ['*'] 616 | return _get_files(file_patterns, _glob_pjoin(HERE, root)) 617 | 618 | 619 | def _compile_pattern(pat, ignore_case=True): 620 | """Translate and compile a glob pattern to a regular expression matcher.""" 621 | if isinstance(pat, bytes): 622 | pat_str = pat.decode('ISO-8859-1') 623 | res_str = _translate_glob(pat_str) 624 | res = res_str.encode('ISO-8859-1') 625 | else: 626 | res = _translate_glob(pat) 627 | flags = re.IGNORECASE if ignore_case else 0 628 | return re.compile(res, flags=flags).match 629 | 630 | 631 | def _iexplode_path(path): 632 | """Iterate over all the parts of a path. 633 | 634 | Splits path recursively with os.path.split(). 635 | """ 636 | (head, tail) = os.path.split(path) 637 | if not head or (not tail and head == path): 638 | if head: 639 | yield head 640 | if tail or not head: 641 | yield tail 642 | return 643 | for p in _iexplode_path(head): 644 | yield p 645 | yield tail 646 | 647 | 648 | def _translate_glob(pat): 649 | """Translate a glob PATTERN to a regular expression.""" 650 | translated_parts = [] 651 | for part in _iexplode_path(pat): 652 | translated_parts.append(_translate_glob_part(part)) 653 | os_sep_class = '[%s]' % re.escape(SEPARATORS) 654 | res = _join_translated(translated_parts, os_sep_class) 655 | return '{res}\\Z(?ms)'.format(res=res) 656 | 657 | 658 | def _join_translated(translated_parts, os_sep_class): 659 | """Join translated glob pattern parts. 660 | 661 | This is different from a simple join, as care need to be taken 662 | to allow ** to match ZERO or more directories. 663 | """ 664 | res = '' 665 | for part in translated_parts[:-1]: 666 | if part == '.*': 667 | # drop separator, since it is optional 668 | # (** matches ZERO or more dirs) 669 | res += part 670 | else: 671 | res += part + os_sep_class 672 | 673 | if translated_parts[-1] == '.*': 674 | # Final part is ** 675 | res += '.+' 676 | # Follow stdlib/git convention of matching all sub files/directories: 677 | res += '({os_sep_class}?.*)?'.format(os_sep_class=os_sep_class) 678 | else: 679 | res += translated_parts[-1] 680 | return res 681 | 682 | 683 | def _translate_glob_part(pat): 684 | """Translate a glob PATTERN PART to a regular expression.""" 685 | # Code modified from Python 3 standard lib fnmatch: 686 | if pat == '**': 687 | return '.*' 688 | i, n = 0, len(pat) 689 | res = [] 690 | while i < n: 691 | c = pat[i] 692 | i = i + 1 693 | if c == '*': 694 | # Match anything but path separators: 695 | res.append('[^%s]*' % SEPARATORS) 696 | elif c == '?': 697 | res.append('[^%s]?' % SEPARATORS) 698 | elif c == '[': 699 | j = i 700 | if j < n and pat[j] == '!': 701 | j = j + 1 702 | if j < n and pat[j] == ']': 703 | j = j + 1 704 | while j < n and pat[j] != ']': 705 | j = j + 1 706 | if j >= n: 707 | res.append('\\[') 708 | else: 709 | stuff = pat[i:j].replace('\\', '\\\\') 710 | i = j + 1 711 | if stuff[0] == '!': 712 | stuff = '^' + stuff[1:] 713 | elif stuff[0] == '^': 714 | stuff = '\\' + stuff 715 | res.append('[%s]' % stuff) 716 | else: 717 | res.append(re.escape(c)) 718 | return ''.join(res) 719 | -------------------------------------------------------------------------------- /src/enableMIDI.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { PromiseDelegate } from '@phosphor/coreutils'; 5 | import midi from 'webmidi'; 6 | 7 | export default async function enableMIDI() { 8 | if (midi.enabled) { 9 | return; 10 | } 11 | 12 | const midiEnabled = new PromiseDelegate(); 13 | midi.enable(function(err) { 14 | if (err) { 15 | midiEnabled.reject(err); 16 | } else { 17 | midiEnabled.resolve(undefined); 18 | } 19 | }); 20 | await midiEnabled.promise; 21 | // if (!(midi.inputs[1] && midi.outputs[1])) { 22 | // throw new Error("Could not find MIDI device"); 23 | // } 24 | } 25 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | // Entry point for the notebook bundle containing custom model definitions. 5 | // 6 | // Setup notebook base URL 7 | // 8 | // Some static assets may be required by the custom widget javascript. The base 9 | // url for the notebook is not known at build time and is therefore computed 10 | // dynamically. 11 | (window as any).__webpack_public_path__ = document.querySelector('body')!.getAttribute('data-base-url') + 'nbextensions/@jupyter-widgets/midicontrols'; 12 | 13 | import enableMidi from './enableMIDI'; 14 | 15 | enableMidi(); 16 | 17 | export * from './index'; 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | export * from './version'; 5 | export * from './widget'; 6 | -------------------------------------------------------------------------------- /src/midi.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { WebMidi } from 'webmidi'; 5 | 6 | export type MIDIInput = WebMidi['inputs'][0]; 7 | export type MIDIOutput = WebMidi['outputs'][0]; 8 | 9 | export type MIDIController = { 10 | input: MIDIInput; 11 | output: MIDIOutput; 12 | } 13 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | Application, IPlugin 6 | } from '@phosphor/application'; 7 | 8 | import { 9 | Widget 10 | } from '@phosphor/widgets'; 11 | 12 | import { 13 | IJupyterWidgetRegistry 14 | } from '@jupyter-widgets/base'; 15 | 16 | import * as widgetExports from './widget'; 17 | import enableMidi from './enableMIDI'; 18 | 19 | import { 20 | MODULE_NAME, MODULE_VERSION 21 | } from './version'; 22 | 23 | const EXTENSION_ID = '@jupyter-widgets/midicontrols:plugin'; 24 | 25 | /** 26 | * The example plugin. 27 | */ 28 | const examplePlugin: IPlugin, void> = { 29 | id: EXTENSION_ID, 30 | requires: [IJupyterWidgetRegistry], 31 | activate: activateWidgetExtension, 32 | autoStart: true 33 | }; 34 | 35 | export default examplePlugin; 36 | 37 | 38 | /** 39 | * Activate the widget extension. 40 | */ 41 | async function activateWidgetExtension(app: Application, registry: IJupyterWidgetRegistry): Promise { 42 | registry.registerWidget({ 43 | name: MODULE_NAME, 44 | version: MODULE_VERSION, 45 | exports: widgetExports, 46 | }); 47 | 48 | await enableMidi(); 49 | } 50 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | const data = require('../package.json'); 5 | 6 | /** 7 | * The _model_module_version/_view_module_version this package implements. 8 | * 9 | * The html widget manager assumes that this is the same as the npm package 10 | * version number. 11 | */ 12 | export const MODULE_VERSION = data.version; 13 | 14 | /* 15 | * The current package name. 16 | */ 17 | export const MODULE_NAME = data.name; 18 | -------------------------------------------------------------------------------- /src/widget.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import midi from 'webmidi'; 5 | 6 | import { 7 | unpack_models, 8 | DOMWidgetModel, 9 | DOMWidgetView, 10 | ISerializers, 11 | resolvePromisesDict 12 | } from '@jupyter-widgets/base'; 13 | 14 | import { MODULE_NAME, MODULE_VERSION } from './version'; 15 | import { Button } from './xtouchmini/button'; 16 | import { Rotary } from './xtouchmini/rotary'; 17 | import { Fader } from './xtouchmini/fader'; 18 | 19 | export class ButtonModel extends DOMWidgetModel { 20 | defaults() { 21 | return { 22 | ...super.defaults(), 23 | _model_name: 'ButtonModel', 24 | _model_module: MODULE_NAME, 25 | _model_module_version: MODULE_VERSION, 26 | _view_name: 'ValueView', 27 | _view_module: MODULE_NAME, 28 | _view_module_version: MODULE_VERSION, 29 | 30 | // Private attributes for just this widget 31 | _controller_input: 0, 32 | _controller_output: 0, 33 | _control: 0, 34 | _light: true, 35 | 36 | value: false, 37 | mode: 'momentary' 38 | }; 39 | } 40 | 41 | initialize(attributes: any, options: any) { 42 | super.initialize(attributes, options); 43 | if (!midi.enabled) { 44 | throw new Error('WebMidi library not enabled'); 45 | } 46 | const values = {...this.defaults(), ...attributes}; 47 | const input = midi.inputs[values._controller_input]; 48 | const output = midi.outputs[values._controller_output]; 49 | this._button = new Button({input, output}, values._control, { 50 | mode: values.mode, 51 | light: values._light 52 | }); 53 | this._button.stateChanged.connect((sender, args) => { 54 | switch (args.name) { 55 | case 'toggled': 56 | this.set('value', args.newValue); 57 | break; 58 | case 'mode': 59 | this.set('mode', args.newValue); 60 | break; 61 | } 62 | this.save_changes(); 63 | }); 64 | this.listenTo(this, 'change', () => { 65 | const changed = this.changedAttributes() || {}; 66 | if (changed.hasOwnProperty('value')) { 67 | this._button.toggled = changed.value; 68 | } 69 | if (changed.hasOwnProperty('mode')) { 70 | this._button.mode = changed.mode; 71 | } 72 | }); 73 | } 74 | 75 | destroy(options: any) { 76 | this._button.dispose(); 77 | super.destroy(options); 78 | } 79 | 80 | private _button: Button; 81 | } 82 | 83 | export class RotaryModel extends DOMWidgetModel { 84 | defaults() { 85 | return { 86 | ...super.defaults(), 87 | _model_name: 'RotaryModel', 88 | _model_module: MODULE_NAME, 89 | _model_module_version: MODULE_VERSION, 90 | _view_name: 'ValueView', 91 | _view_module: MODULE_NAME, 92 | _view_module_version: MODULE_VERSION, 93 | 94 | // Private attributes for just this widget 95 | _controller_input: 0, 96 | _controller_output: 0, 97 | _control: 0, 98 | 99 | value: 0, 100 | light_mode: 'single', 101 | min: 0, 102 | max: 100 103 | }; 104 | } 105 | 106 | initialize(attributes: any, options: any) { 107 | super.initialize(attributes, options); 108 | if (!midi.enabled) { 109 | throw new Error('WebMidi library not enabled'); 110 | } 111 | const values = {...this.defaults(), ...attributes}; 112 | const input = midi.inputs[values._controller_input]; 113 | const output = midi.outputs[values._controller_output]; 114 | this._rotary = new Rotary({input, output}, values._control, { 115 | lightMode: values.light_mode, 116 | min: values.min, 117 | max: values.max, 118 | value: values.value 119 | }); 120 | this._rotary.stateChanged.connect((sender, args) => { 121 | switch (args.name) { 122 | case 'value': 123 | case 'min': 124 | case 'max': 125 | this.set(args.name, args.newValue); 126 | break; 127 | case 'lightMode': 128 | this.set('light_mode', args.newValue); 129 | break; 130 | } 131 | this.save_changes(); 132 | }); 133 | this.listenTo(this, 'change', () => { 134 | const changed = this.changedAttributes() || {}; 135 | if (changed.hasOwnProperty('value')) { 136 | this._rotary.value = changed.value; 137 | } 138 | if (changed.hasOwnProperty('light_mode')) { 139 | this._rotary.lightMode = changed.light_mode; 140 | } 141 | if (changed.hasOwnProperty('min')) { 142 | this._rotary.min = changed.min; 143 | } 144 | if (changed.hasOwnProperty('max')) { 145 | this._rotary.max = changed.max; 146 | } 147 | }); 148 | } 149 | 150 | destroy(options: any) { 151 | this._rotary.dispose(); 152 | super.destroy(options); 153 | } 154 | 155 | private _rotary: Rotary; 156 | } 157 | 158 | export class FaderModel extends DOMWidgetModel { 159 | defaults() { 160 | return { 161 | ...super.defaults(), 162 | _model_name: 'FaderModel', 163 | _model_module: MODULE_NAME, 164 | _model_module_version: MODULE_VERSION, 165 | _view_name: 'ValueView', 166 | _view_module: MODULE_NAME, 167 | _view_module_version: MODULE_VERSION, 168 | 169 | // Private attributes for just this widget 170 | _controller_input: 0, 171 | _controller_output: 0, 172 | _control: 0, 173 | 174 | value: 0, 175 | min: 0, 176 | max: 127 177 | }; 178 | } 179 | 180 | initialize(attributes: any, options: any) { 181 | super.initialize(attributes, options); 182 | if (!midi.enabled) { 183 | throw new Error('WebMidi library not enabled'); 184 | } 185 | const values = {...this.defaults(), ...attributes}; 186 | const input = midi.inputs[values._controller_input]; 187 | const output = midi.outputs[values._controller_output]; 188 | this._fader = new Fader({input, output}, values._control, { 189 | min: values.min, 190 | max: values.max, 191 | value: values.value 192 | }); 193 | this._fader.stateChanged.connect((sender, args) => { 194 | switch (args.name) { 195 | case 'value': 196 | case 'min': 197 | case 'max': 198 | this.set(args.name, args.newValue); 199 | break; 200 | } 201 | this.save_changes(); 202 | }); 203 | this.listenTo(this, 'change', () => { 204 | const changed = this.changedAttributes() || {}; 205 | if (changed.hasOwnProperty('value')) { 206 | this._fader.value = changed.value; 207 | } 208 | if (changed.hasOwnProperty('min')) { 209 | this._fader.min = changed.min; 210 | } 211 | if (changed.hasOwnProperty('max')) { 212 | this._fader.max = changed.max; 213 | } 214 | }); 215 | } 216 | 217 | destroy(options: any) { 218 | this._fader.dispose(); 219 | super.destroy(options); 220 | } 221 | 222 | private _fader: Fader; 223 | } 224 | 225 | export class XTouchMiniModel extends DOMWidgetModel { 226 | defaults() { 227 | return { 228 | ...super.defaults(), 229 | _model_name: 'XTouchMiniModel', 230 | _model_module: MODULE_NAME, 231 | _model_module_version: MODULE_VERSION, 232 | _view_name: 'XTouchMiniView', 233 | _view_module: MODULE_NAME, 234 | _view_module_version: MODULE_VERSION, 235 | buttons: [], 236 | side_buttons: [], 237 | rotary_encoders: [], 238 | rotary_buttons: [], 239 | faders: [], 240 | 241 | _controller_input: 0, 242 | _controller_output: 0, 243 | }; 244 | } 245 | 246 | static serializers: ISerializers = { 247 | ...DOMWidgetModel.serializers, 248 | buttons: { deserialize: unpack_models }, 249 | side_buttons: { deserialize: unpack_models }, 250 | rotary: { deserialize: unpack_models }, 251 | rotary_buttons: { deserialize: unpack_models }, 252 | fader: { deserialize: unpack_models } 253 | }; 254 | 255 | initialize(attributes: any, options: any) { 256 | super.initialize(attributes, options); 257 | if (!midi.enabled) { 258 | throw new Error('WebMidi library not enabled'); 259 | } 260 | 261 | const input = midi.inputs.findIndex(x => x.manufacturer === "Behringer" && x.name.startsWith("X-TOUCH MINI")); 262 | if (input === -1) { 263 | throw new Error("Could not find Behringer X-TOUCH MINI input"); 264 | } 265 | 266 | const output = midi.outputs.findIndex(x => x.manufacturer === "Behringer" && x.name.startsWith("X-TOUCH MINI")); 267 | if (output === -1) { 268 | throw new Error("Could not find Behringer X-TOUCH MINI output"); 269 | } 270 | 271 | this.set('_controller_input', input); 272 | this.set('_controller_output', output); 273 | 274 | 275 | // Make sure we are in MCU protocol mode 276 | midi.outputs[output].sendChannelMode( 277 | 127, 278 | 1 /* MCU mode */, 279 | 1 /* global channel */ 280 | ); 281 | this.setup().then((controls: any) => { 282 | this.set(controls); 283 | this.save_changes(); 284 | }); 285 | } 286 | 287 | async setup() { 288 | // Create control widgets 289 | return resolvePromisesDict({ 290 | buttons: Promise.all(this._createButtons()), 291 | side_buttons: Promise.all(this._createSideButtons()), 292 | rotary_encoders: Promise.all(this._createRotaryEncoders()), 293 | rotary_buttons: Promise.all(this._createRotaryButtons()), 294 | faders: Promise.all(this._createFaders()) 295 | }); 296 | } 297 | 298 | _createButtons() { 299 | // Buttons are indexed top row left to right, then bottom row left to right. 300 | return [ 301 | 0x59, 302 | 0x5a, 303 | 0x28, 304 | 0x29, 305 | 0x2a, 306 | 0x2b, 307 | 0x2c, 308 | 0x2d, 309 | 0x57, 310 | 0x58, 311 | 0x5b, 312 | 0x5c, 313 | 0x56, 314 | 0x5d, 315 | 0x5e, 316 | 0x5f 317 | ].map(_control => 318 | this._createButtonModel({ 319 | _control, 320 | _controller_input: this.get('_controller_input'), 321 | _controller_output: this.get('_controller_output') 322 | }) 323 | ); 324 | } 325 | 326 | _createSideButtons() { 327 | // Buttons are indexed top to bottom. 328 | return [0x54, 0x55].map(_control => 329 | this._createButtonModel({ 330 | _control, 331 | _controller_input: this.get('_controller_input'), 332 | _controller_output: this.get('_controller_output') 333 | }) 334 | ); 335 | } 336 | 337 | _createRotaryButtons() { 338 | // Buttons are indexed left to right. 339 | return [0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27].map(_control => 340 | this._createButtonModel({ 341 | _control, 342 | _controller_input: this.get('_controller_input'), 343 | _controller_output: this.get('_controller_output'), 344 | _light: false 345 | }) 346 | ); 347 | } 348 | 349 | _createRotaryEncoders() { 350 | // Buttons are indexed left to right. 351 | return [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17].map(_control => 352 | this._createRotaryModel({ 353 | _control, 354 | _controller_input: this.get('_controller_input'), 355 | _controller_output: this.get('_controller_output') 356 | }) 357 | ); 358 | } 359 | 360 | _createFaders() { 361 | // Fader are indexed left to right. 362 | return [9].map(_control => 363 | this._createFaderModel({ 364 | _control, 365 | _controller_input: this.get('_controller_input'), 366 | _controller_output: this.get('_controller_output') 367 | }) 368 | ); 369 | } 370 | 371 | /** 372 | * Creates a button widget. 373 | */ 374 | async _createButtonModel(state: any): Promise { 375 | return (await this.widget_manager.new_widget( 376 | { 377 | model_name: 'ButtonModel', 378 | model_module: MODULE_NAME, 379 | model_module_version: MODULE_VERSION, 380 | view_name: 'ValueView', 381 | view_module: MODULE_NAME, 382 | view_module_version: MODULE_VERSION, 383 | }, 384 | state 385 | )) as ButtonModel; 386 | } 387 | 388 | /** 389 | * Creates a rotary encoder widget. 390 | */ 391 | async _createRotaryModel(state: any): Promise { 392 | return (await this.widget_manager.new_widget( 393 | { 394 | model_name: 'RotaryModel', 395 | model_module: MODULE_NAME, 396 | model_module_version: MODULE_VERSION, 397 | view_name: 'ValueView', 398 | view_module: MODULE_NAME, 399 | view_module_version: MODULE_VERSION 400 | }, 401 | state 402 | )) as ButtonModel; 403 | } 404 | 405 | /** 406 | * Creates a fader widget. 407 | */ 408 | async _createFaderModel(state: any): Promise { 409 | return (await this.widget_manager.new_widget( 410 | { 411 | model_name: 'FaderModel', 412 | model_module: MODULE_NAME, 413 | model_module_version: MODULE_VERSION, 414 | view_name: 'ValueView', 415 | view_module: MODULE_NAME, 416 | view_module_version: MODULE_VERSION 417 | }, 418 | state 419 | )) as ButtonModel; 420 | } 421 | } 422 | 423 | export class ValueView extends DOMWidgetView { 424 | render() { 425 | this.value_changed(); 426 | this.model.on('change:value', this.value_changed, this); 427 | } 428 | 429 | value_changed() { 430 | this.el.textContent = this.model.get('value'); 431 | } 432 | } 433 | -------------------------------------------------------------------------------- /src/xtouchmini/button.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { MIDIController } from '../midi'; 5 | 6 | import { ISignal, Signal } from '@phosphor/signaling'; 7 | import { IChangedArgs } from './utils'; 8 | import { Disposable } from './disposable'; 9 | 10 | /** 11 | * A button 12 | */ 13 | export class Button extends Disposable { 14 | /** 15 | * @param input - MIDI input 16 | * @param control - the note number corresponding to the button 17 | * @param light - true when there is an indicator light for the button that 18 | * should reflect the toggle state. 19 | */ 20 | constructor( 21 | controller: MIDIController, 22 | control: number, 23 | { mode = 'momentary', light = true }: Button.IOptions = {} 24 | ) { 25 | super(); 26 | this._controller = controller; 27 | this._control = control; 28 | this._mode = mode; 29 | this._light = light; 30 | 31 | controller.input.addListener('noteon', 1, e => { 32 | if (this.mode === 'momentary' && e.note.number === this._control) { 33 | this.toggled = !this._toggled; 34 | this.refresh(); 35 | } 36 | }); 37 | 38 | controller.input.addListener('noteoff', 1, e => { 39 | if (e.note.number === this._control) { 40 | this._click.emit(undefined); 41 | this.toggled = !this._toggled; 42 | this.refresh(); 43 | } 44 | }); 45 | } 46 | 47 | get toggled() { 48 | return this._toggled; 49 | } 50 | set toggled(newValue: boolean) { 51 | const oldValue = this._toggled; 52 | if (oldValue !== newValue) { 53 | this._toggled = newValue; 54 | this.refresh(); 55 | this._stateChanged.emit({ name: 'toggled', oldValue, newValue }); 56 | } 57 | } 58 | 59 | /** 60 | * The toggle mode for the button. 61 | * 62 | * The mode can be: 63 | * * 'momentary' - the button state is only changed momentarily while the button is held down 64 | * * 'toggle' - each full click of the button toggles the state 65 | */ 66 | get mode() { 67 | return this._mode; 68 | } 69 | set mode(newValue: Button.ButtonMode) { 70 | const oldValue = this._mode; 71 | if (oldValue !== newValue) { 72 | this._mode = newValue; 73 | this.refresh(); 74 | this._stateChanged.emit({ name: 'mode', oldValue, newValue }); 75 | } 76 | } 77 | 78 | /** 79 | * Set the button light, if available. 80 | * 81 | * Possible values are: 82 | * * 0: off 83 | * * 1: blinking 84 | * * 127 (0x7F): on 85 | */ 86 | setButtonLight(value: 0 | 1 | 127) { 87 | if (this._light) { 88 | this._controller.output.playNote(this._control, 1, { 89 | velocity: value, 90 | rawVelocity: true 91 | }); 92 | } 93 | } 94 | 95 | /** 96 | * Refresh the indicated button state. 97 | */ 98 | refresh() { 99 | this.setButtonLight(this._toggled ? 0x7f : 0); 100 | } 101 | 102 | /** 103 | * A signal fired when a button is clicked. 104 | */ 105 | get click(): ISignal { 106 | return this._click; 107 | } 108 | 109 | /** 110 | * A signal fired when the widget state changes. 111 | */ 112 | get stateChanged(): ISignal { 113 | return this._stateChanged; 114 | } 115 | 116 | private _click = new Signal(this); 117 | private _stateChanged = new Signal(this); 118 | 119 | private _control: number; 120 | private _controller: MIDIController; 121 | private _light: boolean; 122 | private _mode: Button.ButtonMode; 123 | private _toggled = false; 124 | } 125 | 126 | export namespace Button { 127 | export type ButtonMode = 'momentary' | 'toggle'; 128 | 129 | export interface IOptions { 130 | mode?: Button.ButtonMode; 131 | light?: boolean; 132 | } 133 | 134 | export type IStateChanged = 135 | | IChangedArgs 136 | | IChangedArgs; 137 | } 138 | -------------------------------------------------------------------------------- /src/xtouchmini/buttongroup.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { Button } from './button'; 5 | 6 | /** 7 | * A group of toggle buttons, one of which can be selected. 8 | */ 9 | export class ButtonGroup { 10 | constructor(buttons: Button[]) { 11 | this._buttons = buttons; 12 | this._activeIndex = 0; 13 | // Any time a button's state changes, set all other buttons to not toggled. 14 | const updateActive = (button: Button, args: Button.IStateChanged) => { 15 | if (args.name === 'toggled' && args.newValue === true) { 16 | this.activeIndex = this._buttons.indexOf(button); 17 | } 18 | }; 19 | 20 | buttons.forEach(button => { 21 | button.stateChanged.connect(updateActive); 22 | }); 23 | } 24 | 25 | get activeIndex() { 26 | return this._activeIndex; 27 | } 28 | set activeIndex(newValue: number) { 29 | let oldValue = this._activeIndex; 30 | if (oldValue !== newValue) { 31 | this._buttons.forEach((button, j) => { 32 | button.toggled = j === newValue; 33 | }); 34 | } 35 | } 36 | 37 | private _buttons: Button[]; 38 | private _activeIndex: number; 39 | 40 | // Perhaps this logic can be on the python side. We just want a toggle button on the js side? 41 | } 42 | -------------------------------------------------------------------------------- /src/xtouchmini/disposable.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { IDisposable } from '@phosphor/disposable'; 5 | import { Signal, ISignal } from '@phosphor/signaling'; 6 | 7 | export 8 | class Disposable implements IDisposable { 9 | /** 10 | * Dispose of the button. 11 | * 12 | * #### Notes 13 | * It is unsafe to use the button after it has been disposed. 14 | * 15 | * All calls made to this method after the first are a no-op. 16 | */ 17 | dispose(): void { 18 | // Do nothing if the widget is already disposed. 19 | if (this.isDisposed) { 20 | return; 21 | } 22 | 23 | this._isDisposed = true; 24 | this._disposed.emit(undefined); 25 | 26 | // Clear the extra data associated with the widget. 27 | Signal.clearData(this); 28 | } 29 | 30 | /** 31 | * Test whether the widget has been disposed. 32 | */ 33 | get isDisposed(): boolean { 34 | return this._isDisposed; 35 | } 36 | 37 | /** 38 | * A signal emitted when the widget is disposed. 39 | */ 40 | get disposed(): ISignal { 41 | return this._disposed; 42 | } 43 | 44 | private _isDisposed = false; 45 | private _disposed = new Signal(this); 46 | } 47 | -------------------------------------------------------------------------------- /src/xtouchmini/fader.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { MIDIController } from '../midi'; 5 | import { clamp, IChangedArgs } from './utils'; 6 | import { ISignal, Signal } from '@phosphor/signaling'; 7 | import { Disposable } from './disposable'; 8 | 9 | export type MidiChannel = 10 | | 1 11 | | 2 12 | | 3 13 | | 4 14 | | 5 15 | | 6 16 | | 7 17 | | 8 18 | | 9 19 | | 10 20 | | 11 21 | | 12 22 | | 13 23 | | 14 24 | | 15 25 | | 16; 26 | 27 | export class Fader extends Disposable { 28 | /** 29 | * control is the midi pitchblend control number. 30 | */ 31 | constructor( 32 | controller: MIDIController, 33 | control: MidiChannel, 34 | { min = 0, max = 127, value = 0, motorized = false }: Fader.IOptions = {} 35 | ) { 36 | super(); 37 | this._control = control; 38 | this._motorized = motorized; 39 | this._min = min; 40 | this._max = max; 41 | // TODO: provide a 'pickup' fader mode? 42 | 43 | controller.input.addListener('pitchbend', this._control, e => { 44 | // for the xtouch mini, we only have the 7 msb of the pitchblend number, 45 | // so we only use e.data[2] 46 | this.value = Math.round((this._max - this._min) * (e.data[2] / 127)) + this._min; 47 | }); 48 | this.value = value; 49 | } 50 | 51 | refresh() { 52 | if (this._motorized) { 53 | // TODO: if this value change came from the controller fader, we don't need to send it back? 54 | const faderValue = 55 | ((this._value - this._min) / (this._max - this._min)) * 2 - 1; 56 | this._controller.output.sendPitchBend(faderValue, this._control); 57 | } 58 | } 59 | 60 | /** 61 | * value goes from min to max, inclusive. 62 | */ 63 | get value() { 64 | return this._value; 65 | } 66 | set value(value: number) { 67 | const newValue = clamp(value, this._min, this._max); 68 | const oldValue = this._value; 69 | if (oldValue !== newValue) { 70 | this._value = newValue; 71 | this.refresh(); 72 | this._stateChanged.emit({ 73 | name: 'value', 74 | oldValue, 75 | newValue 76 | }); 77 | } 78 | } 79 | 80 | /** 81 | * Setting the min may bump the value and max if necessary. 82 | */ 83 | get min() { 84 | return this._min; 85 | } 86 | set min(newValue: number) { 87 | const oldValue = this._min; 88 | if (oldValue !== newValue) { 89 | if (newValue > this.value) { 90 | this.value = newValue; 91 | } 92 | if (newValue > this.max) { 93 | this.max = newValue; 94 | } 95 | this._min = newValue; 96 | this.refresh(); 97 | this._stateChanged.emit({ 98 | name: 'min', 99 | oldValue, 100 | newValue 101 | }); 102 | } 103 | } 104 | 105 | /** 106 | * Setting the max may bump the value and min if necessary. 107 | */ 108 | get max() { 109 | return this._max; 110 | } 111 | set max(newValue: number) { 112 | const oldValue = this._max; 113 | if (oldValue !== newValue) { 114 | if (newValue < this.value) { 115 | this.value = newValue; 116 | } 117 | if (newValue < this.min) { 118 | this.min = newValue; 119 | } 120 | this._max = newValue; 121 | this.refresh(); 122 | this._stateChanged.emit({ 123 | name: 'max', 124 | oldValue, 125 | newValue 126 | }); 127 | } 128 | } 129 | 130 | /** 131 | * A signal fired when the widget state changes. 132 | */ 133 | get stateChanged(): ISignal { 134 | return this._stateChanged; 135 | } 136 | 137 | private _stateChanged = new Signal(this); 138 | 139 | private _controller: MIDIController; 140 | private _control: MidiChannel; 141 | private _max: number; 142 | private _min: number; 143 | private _motorized: boolean; 144 | private _value: number; 145 | } 146 | 147 | export namespace Fader { 148 | export interface IOptions { 149 | min?: number; 150 | max?: number; 151 | value?: number; 152 | motorized?: boolean; 153 | } 154 | 155 | export type IStateChanged = 156 | | IChangedArgs 157 | | IChangedArgs 158 | | IChangedArgs; 159 | } 160 | -------------------------------------------------------------------------------- /src/xtouchmini/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | export { XTouchMini } from './xtouchmini'; 5 | -------------------------------------------------------------------------------- /src/xtouchmini/rotary.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { MIDIController } from '../midi'; 5 | import { clamp, IChangedArgs } from './utils'; 6 | import { ISignal, Signal } from '@phosphor/signaling'; 7 | import { Disposable } from './disposable'; 8 | 9 | export class Rotary extends Disposable { 10 | /** 11 | * control is the midi CC number. 12 | */ 13 | constructor( 14 | controller: MIDIController, 15 | control: number, 16 | { 17 | lightMode = 'single', 18 | min = 0, 19 | max = 100, 20 | value = 50 21 | }: Rotary.IOptions = {} 22 | ) { 23 | super(); 24 | this._controller = controller; 25 | this._control = control; 26 | this._lightMode = lightMode; 27 | this._min = min; 28 | this._max = max; 29 | 30 | controller.input.addListener('controlchange', 1, e => { 31 | if (e.controller.number === this._control) { 32 | // Value is relative 33 | let sign = e.value & 0x40 ? -1 : 1; 34 | let increment = sign * (e.value & 0x3f); 35 | this.value += increment; 36 | } 37 | }); 38 | this.value = value; 39 | } 40 | 41 | refresh() { 42 | let factor = this._lightMode === 'spread' ? 6.999999 : 10.999999; 43 | // assumes 11 leds around rotary, so convert value to between 1 and 11 44 | let leds = 45 | Math.trunc( 46 | ((this._value - this._min) / (this._max - this._min)) * factor 47 | ) + 1; 48 | 49 | this._controller.output.sendControlChange( 50 | 0x20 + this._control, 51 | lightModeNums.get(this._lightMode) + leds, 52 | 1 53 | ); 54 | } 55 | 56 | /** 57 | * value goes from min to max, inclusive. 58 | */ 59 | get value() { 60 | return this._value; 61 | } 62 | set value(value: number) { 63 | const newValue = clamp(value, this._min, this._max); 64 | const oldValue = this._value; 65 | if (oldValue !== newValue) { 66 | this._value = newValue; 67 | this.refresh(); 68 | this._stateChanged.emit({ 69 | name: 'value', 70 | oldValue, 71 | newValue 72 | }); 73 | } 74 | } 75 | 76 | /** 77 | * Setting the min may bump the value and max if necessary. 78 | */ 79 | get min() { 80 | return this._min; 81 | } 82 | set min(newValue: number) { 83 | const oldValue = this._min; 84 | if (oldValue !== newValue) { 85 | if (newValue > this.value) { 86 | this.value = newValue; 87 | } 88 | if (newValue > this.max) { 89 | this.max = newValue; 90 | } 91 | this._min = newValue; 92 | this.refresh(); 93 | this._stateChanged.emit({ 94 | name: 'min', 95 | oldValue, 96 | newValue 97 | }); 98 | } 99 | } 100 | 101 | /** 102 | * Setting the max may bump the value and min if necessary. 103 | */ 104 | get max() { 105 | return this._max; 106 | } 107 | set max(newValue: number) { 108 | const oldValue = this._max; 109 | if (oldValue !== newValue) { 110 | if (newValue < this.value) { 111 | this.value = newValue; 112 | } 113 | if (newValue < this.min) { 114 | this.min = newValue; 115 | } 116 | this._max = newValue; 117 | this.refresh(); 118 | this._stateChanged.emit({ 119 | name: 'max', 120 | oldValue, 121 | newValue 122 | }); 123 | } 124 | } 125 | 126 | /** 127 | * values can be: 128 | * * 'single' - light up a single light on value 129 | * * 'trim' - light from current value to top 130 | * * 'fan' - light up from left to current value 131 | * * 'spread' - light up from top down both sides 132 | */ 133 | get lightMode() { 134 | return this._lightMode; 135 | } 136 | set lightMode(newValue: Rotary.LightMode) { 137 | const oldValue = this._lightMode; 138 | if (oldValue !== newValue) { 139 | this._lightMode = newValue; 140 | this.refresh(); 141 | this._stateChanged.emit({ 142 | name: 'lightMode', 143 | oldValue, 144 | newValue 145 | }); 146 | } 147 | } 148 | 149 | /** 150 | * A signal fired when the widget state changes. 151 | */ 152 | get stateChanged(): ISignal { 153 | return this._stateChanged; 154 | } 155 | 156 | private _stateChanged = new Signal(this); 157 | 158 | private _control: number; 159 | private _controller: MIDIController; 160 | private _lightMode: Rotary.LightMode; 161 | private _max: number; 162 | private _min: number; 163 | private _value: number; 164 | } 165 | 166 | export namespace Rotary { 167 | export interface IOptions { 168 | lightMode?: LightMode; 169 | min?: number; 170 | max?: number; 171 | value?: number; 172 | } 173 | export type LightMode = 'single' | 'trim' | 'wrap' | 'spread'; 174 | 175 | export type IStateChanged = 176 | | IChangedArgs 177 | | IChangedArgs 178 | | IChangedArgs 179 | | IChangedArgs; 180 | } 181 | 182 | /** 183 | * Map from the knob light mode to the appropriate MIDI command. 184 | */ 185 | const lightModeNums = new Map([ 186 | ['single', 0], 187 | ['trim', 0x10], 188 | ['wrap', 0x20], 189 | ['spread', 0x30] 190 | ]); 191 | -------------------------------------------------------------------------------- /src/xtouchmini/utils.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | export function clamp(value: number, min: number, max: number): number { 5 | return Math.min(max, Math.max(min, value)); 6 | } 7 | 8 | /** 9 | * A generic interface for change emitter payloads. 10 | */ 11 | export interface IChangedArgs { 12 | /** 13 | * The name of the changed attribute. 14 | */ 15 | name: U; 16 | /** 17 | * The old value of the changed attribute. 18 | */ 19 | oldValue: T; 20 | /** 21 | * The new value of the changed attribute. 22 | */ 23 | newValue: T; 24 | } 25 | -------------------------------------------------------------------------------- /src/xtouchmini/xtouchmini.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Project Jupyter Contributors 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { MIDIController } from '../midi'; 5 | import { Button } from './button'; 6 | import { Rotary } from './rotary'; 7 | import { Fader } from './fader'; 8 | 9 | /** 10 | * Mini controller in mode. 11 | */ 12 | export class XTouchMini { 13 | constructor(controller: MIDIController) { 14 | // Make sure we are in MCU protocol mode 15 | controller.output.sendChannelMode( 16 | 127, 17 | 1 /* MCU mode */, 18 | 1 /* global channel */ 19 | ); 20 | 21 | // Buttons are indexed top row left to right, then bottom row left to right. 22 | this.buttons = [ 23 | 0x59, 24 | 0x5a, 25 | 0x28, 26 | 0x29, 27 | 0x2a, 28 | 0x2b, 29 | 0x2c, 30 | 0x2d, 31 | 0x57, 32 | 0x58, 33 | 0x5b, 34 | 0x5c, 35 | 0x56, 36 | 0x5d, 37 | 0x5e, 38 | 0x5f 39 | ].map(i => new Button(controller, i, { mode: 'toggle' })); 40 | 41 | // The two buttons on the left side, top then bottom. 42 | this.sideButtons = [0x54, 0x55].map(i => new Button(controller, i)); 43 | 44 | // The press buttons for the rotary encoders, left to right. 45 | this.rotaryButtons = [0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27].map( 46 | i => new Button(controller, i) 47 | ); 48 | 49 | // Rotary encoders, left to right. 50 | this.rotary = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17].map( 51 | i => new Rotary(controller, i) 52 | ); 53 | 54 | this.fader = new Fader(controller, 9); 55 | } 56 | 57 | refresh() { 58 | this.buttons.forEach(b => { 59 | b.refresh(); 60 | }); 61 | } 62 | 63 | readonly rotary: Rotary[]; 64 | readonly rotaryButtons: Button[]; 65 | readonly buttons: Button[]; 66 | readonly sideButtons: Button[]; 67 | readonly fader: Fader; 68 | } 69 | -------------------------------------------------------------------------------- /tests/karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | config.set({ 3 | basePath: '..', 4 | frameworks: ['mocha', 'karma-typescript'], 5 | reporters: ['mocha', 'karma-typescript'], 6 | client: { 7 | mocha: { 8 | timeout : 10000, // 10 seconds - upped from 2 seconds 9 | retries: 3 // Allow for slow server on CI. 10 | } 11 | }, 12 | files: [ 13 | { pattern: "tests/src/**/*.ts" }, 14 | { pattern: "src/**/*.ts" }, 15 | ], 16 | exclude: [ 17 | "src/extension.ts", 18 | ], 19 | preprocessors: { 20 | '**/*.ts': ['karma-typescript'] 21 | }, 22 | browserNoActivityTimeout: 31000, // 31 seconds - upped from 10 seconds 23 | port: 9876, 24 | colors: true, 25 | singleRun: true, 26 | logLevel: config.LOG_INFO, 27 | 28 | 29 | karmaTypescriptConfig: { 30 | tsconfig: 'tests/tsconfig.json', 31 | reports: { 32 | "text-summary": "", 33 | "html": "coverage", 34 | "lcovonly": { 35 | "directory": "coverage", 36 | "filename": "coverage.lcov" 37 | } 38 | } 39 | } 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /tests/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import expect = require('expect.js'); 5 | 6 | import { 7 | // Add any needed widget imports here (or from controls) 8 | } from '@jupyter-widgets/base'; 9 | 10 | import { 11 | createTestModel 12 | } from './utils.spec'; 13 | 14 | import { 15 | ExampleModel, ExampleView 16 | } from '../../src/' 17 | 18 | 19 | describe('Example', () => { 20 | 21 | describe('ExampleModel', () => { 22 | 23 | it('should be createable', () => { 24 | let model = createTestModel(ExampleModel); 25 | expect(model).to.be.an(ExampleModel); 26 | expect(model.get('value')).to.be('Hello World'); 27 | }); 28 | 29 | it('should be createable with a value', () => { 30 | let state = { value: 'Foo Bar!' } 31 | let model = createTestModel(ExampleModel, state); 32 | expect(model).to.be.an(ExampleModel); 33 | expect(model.get('value')).to.be('Foo Bar!'); 34 | }); 35 | 36 | }); 37 | 38 | }); 39 | -------------------------------------------------------------------------------- /tests/src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import * as widgets from '@jupyter-widgets/base'; 5 | import * as services from '@jupyterlab/services'; 6 | import * as Backbone from 'backbone'; 7 | 8 | let numComms = 0; 9 | 10 | export 11 | class MockComm { 12 | target_name = 'dummy'; 13 | 14 | constructor() { 15 | this.comm_id = `mock-comm-id-${numComms}`; 16 | numComms += 1; 17 | } 18 | on_close(fn: Function | null) { 19 | this._on_close = fn; 20 | } 21 | on_msg(fn: Function | null) { 22 | this._on_msg = fn; 23 | } 24 | _process_msg(msg: services.KernelMessage.ICommMsg) { 25 | if (this._on_msg) { 26 | return this._on_msg(msg); 27 | } else { 28 | return Promise.resolve(); 29 | } 30 | } 31 | close(): string { 32 | if (this._on_close) { 33 | this._on_close(); 34 | } 35 | return 'dummy'; 36 | } 37 | send(): string { 38 | return 'dummy'; 39 | } 40 | 41 | open(): string { 42 | return 'dummy'; 43 | } 44 | comm_id: string; 45 | _on_msg: Function | null = null; 46 | _on_close: Function | null = null; 47 | } 48 | 49 | export 50 | class DummyManager extends widgets.ManagerBase { 51 | constructor() { 52 | super(); 53 | this.el = window.document.createElement('div'); 54 | } 55 | 56 | display_view(msg: services.KernelMessage.IMessage, view: Backbone.View, options: any) { 57 | // TODO: make this a spy 58 | // TODO: return an html element 59 | return Promise.resolve(view).then(view => { 60 | this.el.appendChild(view.el); 61 | view.on('remove', () => console.log('view removed', view)); 62 | return view.el; 63 | }); 64 | } 65 | 66 | protected loadClass(className: string, moduleName: string, moduleVersion: string): Promise { 67 | if (moduleName === '@jupyter-widgets/base') { 68 | if ((widgets as any)[className]) { 69 | return Promise.resolve((widgets as any)[className]); 70 | } else { 71 | return Promise.reject(`Cannot find class ${className}`) 72 | } 73 | } else if (moduleName === 'jupyter-datawidgets') { 74 | if (this.testClasses[className]) { 75 | return Promise.resolve(this.testClasses[className]); 76 | } else { 77 | return Promise.reject(`Cannot find class ${className}`) 78 | } 79 | } else { 80 | return Promise.reject(`Cannot find module ${moduleName}`); 81 | } 82 | } 83 | 84 | _get_comm_info() { 85 | return Promise.resolve({}); 86 | } 87 | 88 | _create_comm() { 89 | return Promise.resolve(new MockComm()); 90 | } 91 | 92 | el: HTMLElement; 93 | 94 | testClasses: { [key: string]: any } = {}; 95 | } 96 | 97 | 98 | export 99 | interface Constructor { 100 | new (attributes?: any, options?: any): T; 101 | } 102 | 103 | export 104 | function createTestModel(constructor: Constructor, attributes?: any): T { 105 | let id = widgets.uuid(); 106 | let widget_manager = new DummyManager(); 107 | let modelOptions = { 108 | widget_manager: widget_manager, 109 | model_id: id, 110 | } 111 | 112 | return new constructor(attributes, modelOptions); 113 | } 114 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "noImplicitAny": true, 5 | "lib": ["dom", "es5", "es2015.promise", "es2015.iterable"], 6 | "noEmitOnError": true, 7 | "strictNullChecks": true, 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "target": "ES5", 11 | "outDir": "build", 12 | "skipLibCheck": true, 13 | "sourceMap": true 14 | }, 15 | "include": [ 16 | "src/*.ts", 17 | "../src/**/*.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "outDir": "lib", 17 | "rootDir": "src", 18 | "strict": true, 19 | "strictNullChecks": false, 20 | "target": "es2017", 21 | "types": ["node"] 22 | }, 23 | "include": ["src/**/*.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const version = require('./package.json').version; 3 | 4 | // Custom webpack rules 5 | const rules = [ 6 | { test: /\.ts$/, loader: 'ts-loader' }, 7 | { test: /\.js$/, loader: 'source-map-loader' }, 8 | { test: /\.css$/, use: ['style-loader', 'css-loader']} 9 | ]; 10 | 11 | // Packages that shouldn't be bundled but loaded at runtime 12 | const externals = ['@jupyter-widgets/base']; 13 | 14 | const resolve = { 15 | // Add '.ts' and '.tsx' as resolvable extensions. 16 | extensions: [".webpack.js", ".web.js", ".ts", ".js"] 17 | }; 18 | 19 | module.exports = [ 20 | /** 21 | * Notebook extension 22 | * 23 | * This bundle only contains the part of the JavaScript that is run on load of 24 | * the notebook. 25 | */ 26 | { 27 | entry: './src/extension.ts', 28 | output: { 29 | filename: 'index.js', 30 | path: path.resolve(__dirname, 'ipymidicontrols', 'nbextension', 'static'), 31 | libraryTarget: 'amd' 32 | }, 33 | module: { 34 | rules: rules 35 | }, 36 | devtool: 'source-map', 37 | externals, 38 | resolve, 39 | }, 40 | 41 | /** 42 | * Embeddable @jupyter-widgets/midicontrols bundle 43 | * 44 | * This bundle is almost identical to the notebook extension bundle. The only 45 | * difference is in the configuration of the webpack public path for the 46 | * static assets. 47 | * 48 | * The target bundle is always `dist/index.js`, which is the path required by 49 | * the custom widget embedder. 50 | */ 51 | { 52 | entry: './src/index.ts', 53 | output: { 54 | filename: 'index.js', 55 | path: path.resolve(__dirname, 'dist'), 56 | libraryTarget: 'amd', 57 | library: "@jupyter-widgets/midicontrols", 58 | publicPath: 'https://unpkg.com/@jupyter-widgets/midicontrols@' + version + '/dist/' 59 | }, 60 | devtool: 'source-map', 61 | module: { 62 | rules: rules 63 | }, 64 | externals, 65 | resolve, 66 | }, 67 | 68 | 69 | /** 70 | * Documentation widget bundle 71 | * 72 | * This bundle is used to embed widgets in the package documentation. 73 | */ 74 | { 75 | entry: './src/index.ts', 76 | output: { 77 | filename: 'embed-bundle.js', 78 | path: path.resolve(__dirname, 'docs', 'source', '_static'), 79 | library: "@jupyter-widgets/midicontrols", 80 | libraryTarget: 'amd' 81 | }, 82 | module: { 83 | rules: rules 84 | }, 85 | devtool: 'source-map', 86 | externals, 87 | resolve, 88 | } 89 | 90 | ]; 91 | --------------------------------------------------------------------------------