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.
@@ -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)
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}