composer-nb 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- composer_nb-0.1.0/.gitignore +6 -0
- composer_nb-0.1.0/PKG-INFO +168 -0
- composer_nb-0.1.0/README.md +148 -0
- composer_nb-0.1.0/hatch_build.py +21 -0
- composer_nb-0.1.0/pyproject.toml +44 -0
- composer_nb-0.1.0/python/composer_nb/__init__.py +38 -0
- composer_nb-0.1.0/python/composer_nb/completer.py +148 -0
- composer_nb-0.1.0/python/composer_nb/magic.py +53 -0
- composer_nb-0.1.0/python/composer_nb/song.py +59 -0
- composer_nb-0.1.0/python/composer_nb/static/widget.css +1 -0
- composer_nb-0.1.0/python/composer_nb/static/widget.js +15012 -0
- composer_nb-0.1.0/python/composer_nb/widget.py +23 -0
- composer_nb-0.1.0/tests/python/test_completer.py +118 -0
- composer_nb-0.1.0/tests/python/test_magic.py +79 -0
- composer_nb-0.1.0/tests/python/test_song.py +48 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: composer-nb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small language for sketching chord progressions, played inside Jupyter notebooks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/pkhambat1/composer-nb
|
|
6
|
+
Project-URL: Issues, https://github.com/pkhambat1/composer-nb/issues
|
|
7
|
+
Author: Pezanne Khambatta
|
|
8
|
+
Keywords: chords,dsl,jupyter,music,notebook
|
|
9
|
+
Classifier: Framework :: Jupyter
|
|
10
|
+
Classifier: Intended Audience :: Education
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Artistic Software
|
|
13
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: anywidget>=0.9
|
|
16
|
+
Requires-Dist: ipython>=7.23
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# composer-nb
|
|
22
|
+
|
|
23
|
+
A small language for chords and drums, played right inside Jupyter notebooks.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
%%music
|
|
27
|
+
time: 7/8
|
|
28
|
+
crash: 1|.|.
|
|
29
|
+
ride.bell: x..
|
|
30
|
+
kick: x..x...
|
|
31
|
+
snare: ....x..
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Each `%%music` cell renders to audio in the notebook, with a play button, a waveform, a drum grid, the chords it heard and any mistakes it found.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install composer-nb
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then, in a notebook:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
%load_ext composer_nb
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
It works in JupyterLab, Jupyter Notebook 7, VS Code and Google Colab. Sounds are rendered in your browser, and the piano, guitar and drum samples load from the web, so you need an internet connection.
|
|
49
|
+
|
|
50
|
+
## Cells that continue from each other
|
|
51
|
+
|
|
52
|
+
Give a cell a name, and later cells can start from the settings and words it ended with:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
%%music intro
|
|
56
|
+
tempo: 75
|
|
57
|
+
capo: 2
|
|
58
|
+
sound: guitar
|
|
59
|
+
Am E7|G D
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
%%music verse after intro
|
|
64
|
+
F C|Dm E7
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`verse` plays on guitar with a capo at fret 2, at 75 bpm, wherever it sits in the notebook and whenever you run it. If `intro` hasn't been run yet, you get an error saying so. A cell can still change anything it inherits: `tempo: 90` in `verse` speeds up just `verse`.
|
|
68
|
+
|
|
69
|
+
A cell with no name plays on its own and isn't saved.
|
|
70
|
+
|
|
71
|
+
## From Python
|
|
72
|
+
|
|
73
|
+
`%%music` is a shortcut for `Song`, which you can use directly:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from composer_nb import Song
|
|
77
|
+
|
|
78
|
+
intro = Song("tempo: 75\nsound: guitar\nAm E7|G D", name="intro")
|
|
79
|
+
verse = Song("F C|Dm E7", after=intro, name="verse")
|
|
80
|
+
verse # shows the player
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Songs are ordinary Python values, so you can generate them with loops and functions:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
blues = "|".join(["A7", "D7", "A7", "A7", "D7", "D7", "A7", "A7", "E7", "D7", "A7", "E7"])
|
|
87
|
+
Song("tempo: 120\n" + blues)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## The language
|
|
91
|
+
|
|
92
|
+
Every line is a setting, an instrument and what it plays, a line of chords, a word you can reuse, or a `--` comment. Lines next to each other play together; a blank line starts the next block, which plays after it.
|
|
93
|
+
|
|
94
|
+
### Settings
|
|
95
|
+
|
|
96
|
+
| Setting | Default | Meaning |
|
|
97
|
+
| -------- | --------- | ----------------------------------------------------------------------------- |
|
|
98
|
+
| `time` | `4/4` | `4/4`, `7/8`, or added-up like `(3+4)/4` |
|
|
99
|
+
| `tempo` | `120` | Quarter notes per minute |
|
|
100
|
+
| `sound` | `piano` | What chord lines play on: `piano`, `epiano`, `organ`, `pad`, `bass`, `guitar` |
|
|
101
|
+
| `capo` | `0` | Capo fret, 0 to 12. Only for guitar |
|
|
102
|
+
| `key` | `C` | Key for Roman numeral chords |
|
|
103
|
+
| `step` | `1/16` | Length of each step in a drum loop |
|
|
104
|
+
| `bars` | automatic | How many bars this block plays |
|
|
105
|
+
| `octave` | `3` | Octave chords are voiced in |
|
|
106
|
+
| `kit` | `rock` | Drum sound: `rock` or `synth` |
|
|
107
|
+
|
|
108
|
+
Settings carry on into the blocks and cells below.
|
|
109
|
+
|
|
110
|
+
### Chords
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
sound: guitar
|
|
114
|
+
Am F|C G
|
|
115
|
+
C . . G|Am . F G|%|F . _ .
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`|` is a bar line and the chords in a bar split it evenly. `.` holds the chord before for another slot, `_` is silence and `%` repeats the bar before. Chord lines next to each other carry on from one another. Chords can be names (`Cmaj7`, `F#m`, `Bb7`, `C/E`) or Roman numerals that follow `key` (`I vi IV V7`). To layer a second instrument, start its line with its name: `bass: C A|F G`.
|
|
119
|
+
|
|
120
|
+
### Drums
|
|
121
|
+
|
|
122
|
+
`kick`, `snare`, `hat`, `ride`, `crash`, `tom` and `floor` each get their own line, played either on beats or as a loop. Some have a second sound with a line of its own: `ride.bell`, `hat.open` and `hat.pedal`.
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
kick: 1 3
|
|
126
|
+
snare: 2 4 accent
|
|
127
|
+
crash: 1|.|.
|
|
128
|
+
hat: X.x.X.x.
|
|
129
|
+
ride.bell: x..
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Beats are counted `1 e & a 2 e & a`, and `|` separates bars (`.` is an empty bar). A loop is a row of steps that repeats on its own: `x` hit, `X` accent, `g` ghost, `d` double, `.` rest. Every line in a block repeats until they all line up again.
|
|
133
|
+
|
|
134
|
+
### Repeats
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
repeat 2:
|
|
138
|
+
repeat 3:
|
|
139
|
+
kick: x..x...
|
|
140
|
+
crash: 1
|
|
141
|
+
snare: 2 4
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`repeat 2:` plays the lines indented under it twice. It ends at the first line that isn't indented under it, and repeats nest.
|
|
145
|
+
|
|
146
|
+
### Words
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
pair = X.x.
|
|
150
|
+
verse = Am E7|G D
|
|
151
|
+
hat: pair pair
|
|
152
|
+
verse verse
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Playground
|
|
156
|
+
|
|
157
|
+
The same language runs in the composer-nb playground, a notebook-style web app. Its source is in the [`playground/`](https://github.com/pkhambat1/composer-nb/tree/main/playground) folder of the repository.
|
|
158
|
+
|
|
159
|
+
## Developing
|
|
160
|
+
|
|
161
|
+
This package lives in the `dsl/` folder of [the repository](https://github.com/pkhambat1/composer-nb). The parser and audio engine are JavaScript (`dsl/js/`), and the Python side (`dsl/python/composer_nb/`) passes cell sources to them through an [anywidget](https://anywidget.dev). Build the widget before installing:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
npm install
|
|
165
|
+
npm run build -w dsl
|
|
166
|
+
pip install -e "dsl[test]"
|
|
167
|
+
pytest dsl/tests/python
|
|
168
|
+
```
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# composer-nb
|
|
2
|
+
|
|
3
|
+
A small language for chords and drums, played right inside Jupyter notebooks.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
%%music
|
|
7
|
+
time: 7/8
|
|
8
|
+
crash: 1|.|.
|
|
9
|
+
ride.bell: x..
|
|
10
|
+
kick: x..x...
|
|
11
|
+
snare: ....x..
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Each `%%music` cell renders to audio in the notebook, with a play button, a waveform, a drum grid, the chords it heard and any mistakes it found.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install composer-nb
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then, in a notebook:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
%load_ext composer_nb
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
It works in JupyterLab, Jupyter Notebook 7, VS Code and Google Colab. Sounds are rendered in your browser, and the piano, guitar and drum samples load from the web, so you need an internet connection.
|
|
29
|
+
|
|
30
|
+
## Cells that continue from each other
|
|
31
|
+
|
|
32
|
+
Give a cell a name, and later cells can start from the settings and words it ended with:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
%%music intro
|
|
36
|
+
tempo: 75
|
|
37
|
+
capo: 2
|
|
38
|
+
sound: guitar
|
|
39
|
+
Am E7|G D
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
%%music verse after intro
|
|
44
|
+
F C|Dm E7
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`verse` plays on guitar with a capo at fret 2, at 75 bpm, wherever it sits in the notebook and whenever you run it. If `intro` hasn't been run yet, you get an error saying so. A cell can still change anything it inherits: `tempo: 90` in `verse` speeds up just `verse`.
|
|
48
|
+
|
|
49
|
+
A cell with no name plays on its own and isn't saved.
|
|
50
|
+
|
|
51
|
+
## From Python
|
|
52
|
+
|
|
53
|
+
`%%music` is a shortcut for `Song`, which you can use directly:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from composer_nb import Song
|
|
57
|
+
|
|
58
|
+
intro = Song("tempo: 75\nsound: guitar\nAm E7|G D", name="intro")
|
|
59
|
+
verse = Song("F C|Dm E7", after=intro, name="verse")
|
|
60
|
+
verse # shows the player
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Songs are ordinary Python values, so you can generate them with loops and functions:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
blues = "|".join(["A7", "D7", "A7", "A7", "D7", "D7", "A7", "A7", "E7", "D7", "A7", "E7"])
|
|
67
|
+
Song("tempo: 120\n" + blues)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## The language
|
|
71
|
+
|
|
72
|
+
Every line is a setting, an instrument and what it plays, a line of chords, a word you can reuse, or a `--` comment. Lines next to each other play together; a blank line starts the next block, which plays after it.
|
|
73
|
+
|
|
74
|
+
### Settings
|
|
75
|
+
|
|
76
|
+
| Setting | Default | Meaning |
|
|
77
|
+
| -------- | --------- | ----------------------------------------------------------------------------- |
|
|
78
|
+
| `time` | `4/4` | `4/4`, `7/8`, or added-up like `(3+4)/4` |
|
|
79
|
+
| `tempo` | `120` | Quarter notes per minute |
|
|
80
|
+
| `sound` | `piano` | What chord lines play on: `piano`, `epiano`, `organ`, `pad`, `bass`, `guitar` |
|
|
81
|
+
| `capo` | `0` | Capo fret, 0 to 12. Only for guitar |
|
|
82
|
+
| `key` | `C` | Key for Roman numeral chords |
|
|
83
|
+
| `step` | `1/16` | Length of each step in a drum loop |
|
|
84
|
+
| `bars` | automatic | How many bars this block plays |
|
|
85
|
+
| `octave` | `3` | Octave chords are voiced in |
|
|
86
|
+
| `kit` | `rock` | Drum sound: `rock` or `synth` |
|
|
87
|
+
|
|
88
|
+
Settings carry on into the blocks and cells below.
|
|
89
|
+
|
|
90
|
+
### Chords
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
sound: guitar
|
|
94
|
+
Am F|C G
|
|
95
|
+
C . . G|Am . F G|%|F . _ .
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`|` is a bar line and the chords in a bar split it evenly. `.` holds the chord before for another slot, `_` is silence and `%` repeats the bar before. Chord lines next to each other carry on from one another. Chords can be names (`Cmaj7`, `F#m`, `Bb7`, `C/E`) or Roman numerals that follow `key` (`I vi IV V7`). To layer a second instrument, start its line with its name: `bass: C A|F G`.
|
|
99
|
+
|
|
100
|
+
### Drums
|
|
101
|
+
|
|
102
|
+
`kick`, `snare`, `hat`, `ride`, `crash`, `tom` and `floor` each get their own line, played either on beats or as a loop. Some have a second sound with a line of its own: `ride.bell`, `hat.open` and `hat.pedal`.
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
kick: 1 3
|
|
106
|
+
snare: 2 4 accent
|
|
107
|
+
crash: 1|.|.
|
|
108
|
+
hat: X.x.X.x.
|
|
109
|
+
ride.bell: x..
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Beats are counted `1 e & a 2 e & a`, and `|` separates bars (`.` is an empty bar). A loop is a row of steps that repeats on its own: `x` hit, `X` accent, `g` ghost, `d` double, `.` rest. Every line in a block repeats until they all line up again.
|
|
113
|
+
|
|
114
|
+
### Repeats
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
repeat 2:
|
|
118
|
+
repeat 3:
|
|
119
|
+
kick: x..x...
|
|
120
|
+
crash: 1
|
|
121
|
+
snare: 2 4
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`repeat 2:` plays the lines indented under it twice. It ends at the first line that isn't indented under it, and repeats nest.
|
|
125
|
+
|
|
126
|
+
### Words
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
pair = X.x.
|
|
130
|
+
verse = Am E7|G D
|
|
131
|
+
hat: pair pair
|
|
132
|
+
verse verse
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Playground
|
|
136
|
+
|
|
137
|
+
The same language runs in the composer-nb playground, a notebook-style web app. Its source is in the [`playground/`](https://github.com/pkhambat1/composer-nb/tree/main/playground) folder of the repository.
|
|
138
|
+
|
|
139
|
+
## Developing
|
|
140
|
+
|
|
141
|
+
This package lives in the `dsl/` folder of [the repository](https://github.com/pkhambat1/composer-nb). The parser and audio engine are JavaScript (`dsl/js/`), and the Python side (`dsl/python/composer_nb/`) passes cell sources to them through an [anywidget](https://anywidget.dev). Build the widget before installing:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
npm install
|
|
145
|
+
npm run build -w dsl
|
|
146
|
+
pip install -e "dsl[test]"
|
|
147
|
+
pytest dsl/tests/python
|
|
148
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Stop a build that would ship without the widget's JavaScript.
|
|
2
|
+
|
|
3
|
+
The widget is compiled by `npm run build -w dsl` into python/composer_nb/static/,
|
|
4
|
+
which git ignores. Without this check, forgetting that step produces a wheel
|
|
5
|
+
that installs fine but shows nothing in the notebook.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CustomBuildHook(BuildHookInterface):
|
|
14
|
+
def initialize(self, version, build_data):
|
|
15
|
+
static = Path(self.root) / "python" / "composer_nb" / "static"
|
|
16
|
+
missing = [name for name in ("widget.js", "widget.css") if not (static / name).is_file()]
|
|
17
|
+
if missing:
|
|
18
|
+
raise RuntimeError(
|
|
19
|
+
f"{', '.join(missing)} missing from {static}. "
|
|
20
|
+
"Build the widget first: npm run build -w dsl"
|
|
21
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "composer-nb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A small language for sketching chord progressions, played inside Jupyter notebooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
authors = [{ name = "Pezanne Khambatta" }]
|
|
12
|
+
keywords = ["music", "chords", "jupyter", "notebook", "dsl"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Framework :: Jupyter",
|
|
15
|
+
"Intended Audience :: Education",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Artistic Software",
|
|
18
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"anywidget>=0.9",
|
|
22
|
+
"ipython>=7.23",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
test = ["pytest>=7"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/pkhambat1/composer-nb"
|
|
30
|
+
Issues = "https://github.com/pkhambat1/composer-nb/issues"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["python/composer_nb"]
|
|
34
|
+
artifacts = ["python/composer_nb/static/*"]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.sdist]
|
|
37
|
+
include = ["python", "README.md", "hatch_build.py"]
|
|
38
|
+
artifacts = ["python/composer_nb/static/*"]
|
|
39
|
+
|
|
40
|
+
# Refuses to build without the compiled widget; see hatch_build.py.
|
|
41
|
+
[tool.hatch.build.hooks.custom]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests/python"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""composer-nb: a small language for chords and drums, played inside notebooks.
|
|
2
|
+
|
|
3
|
+
In Jupyter, load the cell magic and write music in `%%music` cells:
|
|
4
|
+
|
|
5
|
+
%load_ext composer_nb
|
|
6
|
+
|
|
7
|
+
%%music intro
|
|
8
|
+
tempo 75
|
|
9
|
+
sound guitar
|
|
10
|
+
play {
|
|
11
|
+
chords: Am E7|G D
|
|
12
|
+
kick: loop x-------
|
|
13
|
+
snare: loop ----x---
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Or build songs from Python with `Song`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
20
|
+
|
|
21
|
+
from .song import Song
|
|
22
|
+
from .widget import MusicWidget
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
__version__ = version("composer-nb")
|
|
26
|
+
except PackageNotFoundError: # running from a source checkout that isn't installed
|
|
27
|
+
__version__ = "0.0.0"
|
|
28
|
+
|
|
29
|
+
__all__ = ["MusicWidget", "Song", "__version__"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_ipython_extension(ipython):
|
|
33
|
+
"""Called by `%load_ext composer_nb`; registers the `%%music` cell magic and its Tab completion."""
|
|
34
|
+
from .completer import register
|
|
35
|
+
from .magic import MusicMagics
|
|
36
|
+
|
|
37
|
+
ipython.register_magics(MusicMagics)
|
|
38
|
+
register(ipython)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Tab completion inside `%%music` cells.
|
|
2
|
+
|
|
3
|
+
Jupyter asks the kernel what could come next, and IPython only knows Python, so the
|
|
4
|
+
extension adds a matcher of its own. The word lists mirror dsl/js/language.js;
|
|
5
|
+
tests/python/test_completer.py fails if the two drift apart.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from .song import Song
|
|
12
|
+
|
|
13
|
+
DRUMS = ["crash", "ride", "hat", "tom", "floor", "snare", "kick"]
|
|
14
|
+
VARIATIONS = {"ride": ["bell"], "hat": ["open", "pedal"]}
|
|
15
|
+
INSTRUMENTS = ["piano", "epiano", "organ", "pad", "bass", "guitar"]
|
|
16
|
+
SETTINGS = ["time", "tempo", "step", "sound", "key", "capo", "octave", "kit"]
|
|
17
|
+
MODIFIERS = ["accent", "ghost", "double"]
|
|
18
|
+
KITS = ["rock", "synth"]
|
|
19
|
+
|
|
20
|
+
LANES = [lane for d in DRUMS for lane in [d, *(f"{d}.{v}" for v in VARIATIONS.get(d, []))]]
|
|
21
|
+
PARTS = ["chords", *LANES]
|
|
22
|
+
SETTING_VALUES = {"sound": INSTRUMENTS, "kit": KITS}
|
|
23
|
+
|
|
24
|
+
MAGIC = "%%music"
|
|
25
|
+
WORD_RE = re.compile(r"^\s*([A-Za-z_]\w*)\s*=", re.M)
|
|
26
|
+
PATTERN_RE = re.compile(r"\bpattern\s+([A-Za-z_]\w*)")
|
|
27
|
+
# The instrument line the cursor is on: its name, then what's been written after the colon.
|
|
28
|
+
PART_RE = re.compile(r"(?:^|[\s{])([a-z]+(?:\.[a-z]+)?):([^:{}]*)$")
|
|
29
|
+
NUMBER_RE = re.compile(r"^\d+$|\)$")
|
|
30
|
+
|
|
31
|
+
Option = Tuple[str, str] # the text to insert and its kind, which picks the icon
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _options(texts, kind) -> List[Option]:
|
|
35
|
+
return [(t, kind) for t in texts]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _magic_line(line: str, songs: Dict[str, Song]) -> Optional[Tuple[str, List[Option]]]:
|
|
39
|
+
"""Completions for the `%%music [name] [after <name>]` line itself."""
|
|
40
|
+
if not line.startswith(MAGIC + " "):
|
|
41
|
+
return None # still typing the magic's name, which IPython completes
|
|
42
|
+
fragment = re.search(r"\w*$", line).group()
|
|
43
|
+
words = line[len(MAGIC) : len(line) - len(fragment)].split()
|
|
44
|
+
if words and words[-1] == "after":
|
|
45
|
+
return fragment, _options(sorted(songs), "variable")
|
|
46
|
+
if len(words) == 1:
|
|
47
|
+
return fragment, [("after ", "keyword")]
|
|
48
|
+
return fragment, []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _statement(head: str, body: str, patterns: List[str]) -> List[Option]:
|
|
52
|
+
"""What can come next on a line that isn't an instrument's."""
|
|
53
|
+
inside = (body + head).count("{") > (body + head).count("}")
|
|
54
|
+
words = re.split(r"[{}]", head)[-1].split()
|
|
55
|
+
names = _options(patterns, "variable")
|
|
56
|
+
parts = _options([p + ": " for p in PARTS], "property")
|
|
57
|
+
if not words:
|
|
58
|
+
settings = _options([s + " " for s in SETTINGS], "keyword")
|
|
59
|
+
if inside:
|
|
60
|
+
return parts + names + settings
|
|
61
|
+
return settings + [("pattern ", "keyword"), ("play ", "keyword")]
|
|
62
|
+
first, last = words[0], words[-1]
|
|
63
|
+
if first in ("play", "pattern") and len(words) > 1 and NUMBER_RE.search(last):
|
|
64
|
+
return _options(["bars ", "bar "], "keyword")
|
|
65
|
+
if first == "play":
|
|
66
|
+
return names + (parts if len(words) == 1 else [])
|
|
67
|
+
if first == "time" and len(words) > 1 and "over" not in words:
|
|
68
|
+
return [("over ", "keyword")]
|
|
69
|
+
if first in SETTING_VALUES and len(words) == 1:
|
|
70
|
+
return _options(SETTING_VALUES[first], "value")
|
|
71
|
+
if inside and first not in SETTINGS and first != "pattern":
|
|
72
|
+
return names # a line that plays patterns in order: intro verse * 2
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def complete(text: str, cursor: int, songs: Optional[Dict[str, Song]] = None):
|
|
77
|
+
"""Completions at `cursor` in a `%%music` cell (`text` includes the magic line).
|
|
78
|
+
|
|
79
|
+
Returns (fragment, options): the part of a word already typed, and what could
|
|
80
|
+
replace it. None means the cursor isn't somewhere this language applies.
|
|
81
|
+
"""
|
|
82
|
+
songs = songs or {}
|
|
83
|
+
before = text[:cursor]
|
|
84
|
+
start = before.rfind("\n") + 1
|
|
85
|
+
line = before[start:]
|
|
86
|
+
if start == 0:
|
|
87
|
+
return _magic_line(line, songs)
|
|
88
|
+
if "//" in line:
|
|
89
|
+
return "", []
|
|
90
|
+
fragment = re.search(r"[A-Za-z_]\w*$|$", line).group()
|
|
91
|
+
head = line[: len(line) - len(fragment)]
|
|
92
|
+
magic_line, _, body = before[:start].partition("\n")
|
|
93
|
+
|
|
94
|
+
# Names defined above the cursor, in this cell or the cells it continues from.
|
|
95
|
+
after = re.search(r"\bafter\s+(\w+)", magic_line)
|
|
96
|
+
inherited = songs[after.group(1)].chain if after and after.group(1) in songs else []
|
|
97
|
+
defined = "\n".join([*inherited, body])
|
|
98
|
+
words = sorted(set(WORD_RE.findall(defined)))
|
|
99
|
+
patterns = sorted(set(PATTERN_RE.findall(defined)))
|
|
100
|
+
|
|
101
|
+
variation = re.search(r"([a-z]+)\.$", head)
|
|
102
|
+
part = PART_RE.search(head)
|
|
103
|
+
if variation: # ride. -> ride.bell
|
|
104
|
+
options = _options([v + ": " for v in VARIATIONS.get(variation.group(1), [])], "property")
|
|
105
|
+
elif part and part.group(1) in PARTS:
|
|
106
|
+
written = part.group(2).split()
|
|
107
|
+
options = _options(words, "variable")
|
|
108
|
+
if not written:
|
|
109
|
+
options = [("loop ", "keyword")] + options
|
|
110
|
+
elif part.group(1) != "chords" and any(w[0].isdigit() for w in written):
|
|
111
|
+
options = _options(MODIFIERS, "keyword") # beats take accent, ghost, double
|
|
112
|
+
else:
|
|
113
|
+
options = _statement(head, body, patterns)
|
|
114
|
+
return fragment, [o for o in options if o[0].startswith(fragment) and o[0] != fragment]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def register(shell) -> None:
|
|
118
|
+
"""Add the `%%music` matcher to `shell`'s completer, once."""
|
|
119
|
+
try:
|
|
120
|
+
from IPython.core.completer import SimpleCompletion, context_matcher
|
|
121
|
+
except ImportError: # IPython before 8.6 has no matcher API
|
|
122
|
+
return
|
|
123
|
+
identifier = "composer_nb.music_matcher"
|
|
124
|
+
matchers = shell.Completer.custom_matchers
|
|
125
|
+
if any(getattr(m, "matcher_identifier", None) == identifier for m in matchers):
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
@context_matcher(identifier=identifier)
|
|
129
|
+
def music_matcher(context):
|
|
130
|
+
text = context.full_text
|
|
131
|
+
result = None
|
|
132
|
+
if text.startswith(MAGIC):
|
|
133
|
+
lines = text.split("\n")
|
|
134
|
+
cursor = sum(len(l) + 1 for l in lines[: context.cursor_line]) + context.cursor_position
|
|
135
|
+
songs = {k: v for k, v in shell.user_ns.items() if isinstance(v, Song)}
|
|
136
|
+
result = complete(text, cursor, songs)
|
|
137
|
+
if result is None:
|
|
138
|
+
return {"completions": [], "suppress": False}
|
|
139
|
+
fragment, options = result
|
|
140
|
+
return {
|
|
141
|
+
"completions": [SimpleCompletion(text=t, type=kind) for t, kind in options],
|
|
142
|
+
"matched_fragment": fragment,
|
|
143
|
+
# Python names mean nothing in a music cell, so IPython's own matchers stay out.
|
|
144
|
+
# (IPython only honours this when there's something to offer.)
|
|
145
|
+
"suppress": True,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
matchers.append(music_matcher)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import keyword
|
|
2
|
+
from typing import Optional, Tuple
|
|
3
|
+
|
|
4
|
+
from IPython.core.error import UsageError
|
|
5
|
+
from IPython.core.magic import Magics, cell_magic, magics_class
|
|
6
|
+
|
|
7
|
+
from .song import Song
|
|
8
|
+
|
|
9
|
+
USAGE = "Usage: %%music [name] [after <name>]"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_magic_line(line: str) -> Tuple[Optional[str], Optional[str]]:
|
|
13
|
+
"""Split the `%%music` line into (name, after)."""
|
|
14
|
+
words = line.split()
|
|
15
|
+
name = after = None
|
|
16
|
+
if words and words[0] != "after":
|
|
17
|
+
name = words.pop(0)
|
|
18
|
+
if words:
|
|
19
|
+
if words[0] != "after" or len(words) != 2:
|
|
20
|
+
raise UsageError(USAGE)
|
|
21
|
+
after = words[1]
|
|
22
|
+
for word in (name, after):
|
|
23
|
+
if word is not None and (not word.isidentifier() or keyword.iskeyword(word)):
|
|
24
|
+
raise UsageError(
|
|
25
|
+
f"{word!r} can't be a cell name. Use letters, digits and underscores, like intro or verse_2."
|
|
26
|
+
)
|
|
27
|
+
if name is not None and name == after:
|
|
28
|
+
raise UsageError(f"{name} can't continue from itself.")
|
|
29
|
+
return name, after
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@magics_class
|
|
33
|
+
class MusicMagics(Magics):
|
|
34
|
+
@cell_magic
|
|
35
|
+
def music(self, line, cell):
|
|
36
|
+
"""Play a cell of composer-nb source.
|
|
37
|
+
|
|
38
|
+
%%music play this cell on its own
|
|
39
|
+
%%music intro ...and save it as `intro`
|
|
40
|
+
%%music verse after intro start from the settings and definitions `intro` ended with
|
|
41
|
+
"""
|
|
42
|
+
name, after_name = parse_magic_line(line)
|
|
43
|
+
after = None
|
|
44
|
+
if after_name is not None:
|
|
45
|
+
if after_name not in self.shell.user_ns:
|
|
46
|
+
raise UsageError(f"{after_name} hasn't been run yet. Run its cell first.")
|
|
47
|
+
after = self.shell.user_ns[after_name]
|
|
48
|
+
if not isinstance(after, Song):
|
|
49
|
+
raise UsageError(f"{after_name} is a {type(after).__name__}, not a %%music cell.")
|
|
50
|
+
song = Song(cell, after=after, name=name)
|
|
51
|
+
if name is not None:
|
|
52
|
+
self.shell.user_ns[name] = song
|
|
53
|
+
return song
|