composer-nb 0.1.0__py3-none-any.whl
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/__init__.py +38 -0
- composer_nb/completer.py +148 -0
- composer_nb/magic.py +53 -0
- composer_nb/song.py +59 -0
- composer_nb/static/widget.css +1 -0
- composer_nb/static/widget.js +15012 -0
- composer_nb/widget.py +23 -0
- composer_nb-0.1.0.dist-info/METADATA +168 -0
- composer_nb-0.1.0.dist-info/RECORD +10 -0
- composer_nb-0.1.0.dist-info/WHEEL +4 -0
composer_nb/__init__.py
ADDED
|
@@ -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)
|
composer_nb/completer.py
ADDED
|
@@ -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)
|
composer_nb/magic.py
ADDED
|
@@ -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
|
composer_nb/song.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
|
|
3
|
+
from .widget import MusicWidget
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Song:
|
|
7
|
+
"""A cell's worth of composer-nb source.
|
|
8
|
+
|
|
9
|
+
Pass `after` to start from the settings and definitions another song ended
|
|
10
|
+
with (tempo, sound, your own names, ...). The link is to that song object, not to whichever cell
|
|
11
|
+
sits above, so moving cells around or running them out of order doesn't
|
|
12
|
+
change what a song sounds like.
|
|
13
|
+
|
|
14
|
+
intro = Song("tempo 75\\nsound guitar\\nplay chords: Am E7|G D", name="intro")
|
|
15
|
+
verse = Song("play chords: F C|Dm E7", after=intro, name="verse")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, source: str, *, after: Optional["Song"] = None, name: Optional[str] = None):
|
|
19
|
+
if not isinstance(source, str):
|
|
20
|
+
raise TypeError(f"source must be a str, not {type(source).__name__}")
|
|
21
|
+
if after is not None and not isinstance(after, Song):
|
|
22
|
+
raise TypeError(f"after must be a Song, not {type(after).__name__}")
|
|
23
|
+
self.source = source
|
|
24
|
+
self.after = after
|
|
25
|
+
self.name = name
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def chain(self) -> List[str]:
|
|
29
|
+
"""Sources from the start of the `after` chain down to this song."""
|
|
30
|
+
sources = []
|
|
31
|
+
seen = set()
|
|
32
|
+
song = self
|
|
33
|
+
while song is not None:
|
|
34
|
+
if id(song) in seen:
|
|
35
|
+
raise ValueError("songs can't continue from each other in a loop")
|
|
36
|
+
seen.add(id(song))
|
|
37
|
+
sources.append(song.source)
|
|
38
|
+
song = song.after
|
|
39
|
+
return sources[::-1]
|
|
40
|
+
|
|
41
|
+
def widget(self) -> MusicWidget:
|
|
42
|
+
"""The notebook player for this song."""
|
|
43
|
+
return MusicWidget(
|
|
44
|
+
sources=self.chain,
|
|
45
|
+
name=self.name or "",
|
|
46
|
+
after=(self.after.name or "") if self.after else "",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def _repr_mimebundle_(self, include=None, exclude=None):
|
|
50
|
+
bundle = self.widget()._repr_mimebundle_(include=include, exclude=exclude)
|
|
51
|
+
data, metadata = bundle if isinstance(bundle, tuple) else (bundle, {})
|
|
52
|
+
# Terminals and plain-text renderers get the song, not "<MusicWidget object at ...>".
|
|
53
|
+
return {**data, "text/plain": repr(self)}, metadata
|
|
54
|
+
|
|
55
|
+
def __repr__(self):
|
|
56
|
+
parts = [repr(self.name)] if self.name else []
|
|
57
|
+
if self.after is not None:
|
|
58
|
+
parts.append(f"after={self.after.name or 'Song(...)'}")
|
|
59
|
+
return f"Song({', '.join(parts)})"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.cnb{--cnb-accent: var(--jp-brand-color1, #1a73e8);--cnb-text: var(--jp-ui-font-color1, rgba(0, 0, 0, .87));--cnb-mute: var(--jp-ui-font-color2, rgba(0, 0, 0, .54));--cnb-paper: var(--jp-layout-color1, #fff);--cnb-border: var(--jp-border-color2, rgba(0, 0, 0, .12));--cnb-wave: var(--jp-ui-font-color3, rgba(0, 0, 0, .38));--cnb-error: var(--jp-error-color1, #b00020);--cnb-mono: var(--jp-code-font-family, ui-monospace, "SF Mono", Menlo, monospace);display:flex;flex-direction:column;gap:8px;padding:4px 0;color:var(--cnb-text);font-family:var(--jp-ui-font-family, system-ui, sans-serif);font-size:13px}.cnb-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}.cnb-name{font-weight:600}.cnb-after{padding:0 6px;border:1px solid var(--cnb-border);border-radius:999px;color:var(--cnb-mute);font-size:11px}.cnb-settings{color:var(--cnb-mute);font-family:var(--cnb-mono);font-size:11.5px}.cnb-status{color:var(--cnb-mute);font-size:12px}.cnb-status-error{color:var(--cnb-error)}.cnb-player{display:flex;align-items:center;gap:10px}.cnb-play{position:relative;flex-shrink:0;width:30px;height:30px;padding:0;border:1px solid var(--cnb-border);border-radius:50%;background:var(--cnb-paper);color:var(--cnb-accent);cursor:pointer}.cnb-play:hover{border-color:var(--cnb-accent)}.cnb-play:before{content:"";position:absolute;top:50%;left:52%;transform:translate(-40%,-50%);border-style:solid;border-width:6px 0 6px 10px;border-color:transparent transparent transparent currentColor}.cnb-play.is-playing:before{left:50%;transform:translate(-50%,-50%);width:3px;height:11px;border:solid currentColor;border-width:0 3px}.cnb-wave{position:relative;flex:1;min-width:0;height:44px;cursor:pointer}.cnb-wave canvas{display:block;height:44px}.cnb-wave-base{width:100%}.cnb-wave-progress{position:absolute;inset:0 auto 0 0;width:0;overflow:hidden}.cnb-time{flex-shrink:0;color:var(--cnb-mute);font-family:var(--cnb-mono);font-size:11.5px;font-variant-numeric:tabular-nums}.cnb-download{flex-shrink:0;padding:3px 8px;border:1px solid var(--cnb-border);border-radius:4px;color:var(--cnb-mute);font-size:11px;text-decoration:none}.cnb-download:hover{border-color:var(--cnb-accent);color:var(--cnb-accent)}.cnb-chords{display:flex;flex-wrap:wrap;gap:6px}.cnb-chip{display:flex;flex-direction:column;gap:2px;min-width:64px;padding:5px 8px;border:1px solid var(--cnb-border);border-radius:4px;background:var(--cnb-paper);font-family:var(--cnb-mono)}.cnb-chip-label{font-size:12px}.cnb-chip-notes{color:var(--cnb-mute);font-size:9.5px;font-weight:600}.cnb-errors{display:flex;flex-direction:column;gap:2px;padding:6px 10px;border-left:3px solid var(--cnb-error);background:color-mix(in srgb,var(--cnb-error) 7%,transparent);font-family:var(--cnb-mono);font-size:11.5px}.cnb-error{display:flex;gap:10px}.cnb-error-line{flex-shrink:0;color:var(--cnb-error)}.cnb-grid{display:flex;flex-direction:column;gap:6px}.cnb-grid-head{margin-top:4px;color:var(--cnb-mute);font-family:var(--cnb-mono);font-size:11px}.cnb-bar{display:grid;gap:2px;align-items:center}.cnb-lane{color:var(--cnb-mute);font-family:var(--cnb-mono);font-size:11px;white-space:nowrap}.cnb-bar-num{font-size:10px}.cnb-cell{height:16px;border-radius:3px;background:color-mix(in srgb,var(--cnb-text) 6%,transparent);color:var(--cnb-paper);font-family:var(--cnb-mono);font-size:10px;font-weight:600;line-height:16px;text-align:center;overflow:hidden}.cnb-alt{background:color-mix(in srgb,var(--cnb-text) 11%,transparent)}.cnb-hit{background:color-mix(in srgb,var(--cnb-accent) 62%,var(--cnb-paper))}.cnb-acc{background:var(--cnb-accent)}.cnb-ghost{opacity:.45}.cnb-cell.cnb-now{outline:1.5px solid var(--cnb-text);outline-offset:-1px}.cnb-count{color:var(--cnb-mute);font-family:var(--cnb-mono);font-size:10px;text-align:center}.cnb-count-beat{color:var(--cnb-text);font-weight:600}.cnb-count.cnb-now{color:var(--cnb-accent);font-weight:700}.cnb-output{display:flex;flex-direction:column;gap:8px}.cnb-output+.cnb-output{margin-top:6px;padding-top:10px;border-top:1px solid var(--cnb-border)}.cnb-output-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}.cnb-play-line{color:var(--cnb-accent);font-family:var(--cnb-mono);font-size:12px;font-weight:600}
|