songleaf 0.0.2__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.
songleaf/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """songleaf: dense, readable one-page song sheets from searchable sources.
2
+
3
+ Find a song, keep it as lyrics plus anchored annotations (sections, chords,
4
+ provenance, score links), and render it onto one A4 page with the lyrics as
5
+ large as will fit.
6
+
7
+ >>> import songleaf
8
+ >>> song = songleaf.parse_chords_over_lyrics("C G\\nHello there my friend")
9
+ >>> [a.body["symbol"] for a in song.of_kind("chord")]
10
+ ['C', 'G']
11
+
12
+ From a query to a PDF, on the Kaggle chords-and-lyrics corpus::
13
+
14
+ songleaf.sheet("wonderwall oasis") # -> {'path': '.../sheets/oasis-wonderwall.pdf', ...}
15
+
16
+ or ``python -m songleaf sheet "wonderwall oasis"``.
17
+ """
18
+
19
+ from songleaf.model import Annotation, Song, chord, score_link, section
20
+ from songleaf.parse import parse_chords_over_lyrics
21
+ from songleaf.render import DenseStyle, fit_font_size, render_dense_a4
22
+ from songleaf.sources import Hit, KaggleChordsSource, get_song, search
23
+ from songleaf.store import song_store
24
+ from songleaf.tools import sheet, songs
25
+
26
+ __all__ = [
27
+ "Annotation",
28
+ "DenseStyle",
29
+ "Hit",
30
+ "KaggleChordsSource",
31
+ "Song",
32
+ "chord",
33
+ "fit_font_size",
34
+ "get_song",
35
+ "parse_chords_over_lyrics",
36
+ "render_dense_a4",
37
+ "score_link",
38
+ "search",
39
+ "section",
40
+ "sheet",
41
+ "song_store",
42
+ "songs",
43
+ ]
songleaf/__main__.py ADDED
@@ -0,0 +1,55 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+ """``python -m songleaf``: the command line, ``cw`` over :data:`songleaf.tools.TOOLS`.
3
+
4
+ Examples::
5
+
6
+ python -m songleaf search "wonderwall oasis"
7
+ python -m songleaf sheet "wonderwall oasis" # -> a one-page A4 PDF
8
+ python -m songleaf sheet kaggle_chords:1234 --output sheet.pdf
9
+ python -m songleaf songs
10
+ """
11
+
12
+ import functools
13
+ import inspect
14
+
15
+ import cw
16
+
17
+ from songleaf import tools
18
+
19
+ #: Dependency-injection parameters: seams for library callers, not CLI options.
20
+ _SEAMS = ("sources", "store", "renderer")
21
+
22
+
23
+ def _with_expected_errors(tool):
24
+ """A failure the user can act on becomes one line on stderr, not a traceback."""
25
+
26
+ @functools.wraps(tool)
27
+ def command(*args, **kwargs):
28
+ try:
29
+ return tool(*args, **kwargs)
30
+ except (LookupError, RuntimeError, ValueError) as error:
31
+ raise cw.CommandError(str(error)) from error
32
+
33
+ return command
34
+
35
+
36
+ def main(argv=None):
37
+ """Run one songleaf command."""
38
+ config = {
39
+ tool.__name__: {
40
+ name: cw.HIDE
41
+ for name in _SEAMS
42
+ if name in inspect.signature(tool).parameters
43
+ }
44
+ for tool in tools.TOOLS
45
+ }
46
+ commands = [_with_expected_errors(tool) for tool in tools.TOOLS]
47
+ raise SystemExit(
48
+ cw.dispatch(
49
+ commands, argv, prog="songleaf", convention=cw.MODERN, config=config
50
+ )
51
+ )
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
songleaf/model.py ADDED
@@ -0,0 +1,154 @@
1
+ """The linked-artifact model: a song is lyrics text plus standoff annotations.
2
+
3
+ A :class:`Song` keeps its lyrics as one plain string and everything else as
4
+ :class:`Annotation` records anchored on character offsets into that string.
5
+ The text is never marked up, so any number of annotation layers can coexist
6
+ and be added later without touching the lyrics (the standoff design of
7
+ ``lacing``, over characters instead of seconds).
8
+
9
+ Kinds used so far:
10
+
11
+ - ``"section"``: spans the section's lines, ``body={"label": "Chorus"}``. A
12
+ label-only section (a bare ``[Chorus]`` marker meaning "repeat the chorus") is
13
+ empty (``start == end``), sits on an empty line of its own, and has
14
+ ``"marker": True`` in its body.
15
+ - ``"chord"``: a point (``start == end``) on the first character of the
16
+ syllable the chord lands on, ``body={"symbol": "G/B", "timing": "at"}``.
17
+ ``timing`` is ``"at"`` (the chord sounds with that syllable, the default) or
18
+ ``"before"`` (it sounds ahead of it). Chords of a line with no lyrics (an
19
+ intro, an instrumental bar) sit, in order, on an empty line of the text.
20
+ - ``"score"``: a link to a score snippet for ``[start, end)``,
21
+ ``body={"source": ..., "id": ..., "url": ..., "format": ...}``.
22
+
23
+ >>> song = Song("Hello there", [chord(0, "C"), chord(6, "G")], meta={"title": "Hi"})
24
+ >>> [(a.start, a.body["symbol"]) for a in song.of_kind("chord")]
25
+ [(0, 'C'), (6, 'G')]
26
+ >>> Song.from_dict(song.to_dict()) == song
27
+ True
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Iterator, Mapping
33
+ from dataclasses import dataclass, field
34
+
35
+ #: When a chord sounds, relative to the syllable it is anchored on.
36
+ CHORD_TIMINGS = ("at", "before")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Annotation:
41
+ """One annotation over ``Song.text[start:end]``; a point when ``start == end``."""
42
+
43
+ kind: str
44
+ start: int
45
+ end: int
46
+ body: Mapping = field(default_factory=dict)
47
+
48
+ def __post_init__(self):
49
+ if not 0 <= self.start <= self.end:
50
+ raise ValueError(
51
+ f"An annotation needs 0 <= start <= end, got [{self.start}, {self.end})"
52
+ )
53
+
54
+ def to_dict(self) -> dict:
55
+ """The JSON-ready form."""
56
+ return {
57
+ "kind": self.kind,
58
+ "start": self.start,
59
+ "end": self.end,
60
+ "body": dict(self.body),
61
+ }
62
+
63
+
64
+ def chord(at: int, symbol: str, *, timing: str = "at") -> Annotation:
65
+ """A chord landing on the syllable that starts at offset ``at``."""
66
+ if timing not in CHORD_TIMINGS:
67
+ raise ValueError(f"timing must be one of {CHORD_TIMINGS}, got {timing!r}")
68
+ return Annotation("chord", at, at, {"symbol": symbol, "timing": timing})
69
+
70
+
71
+ def section(start: int, end: int, label: str) -> Annotation:
72
+ """A section (verse, chorus, ...) spanning ``[start, end)``."""
73
+ return Annotation("section", start, end, {"label": label})
74
+
75
+
76
+ def score_link(
77
+ start: int, end: int, *, source: str, id: str, url: str = "", format: str = ""
78
+ ) -> Annotation:
79
+ """A link from ``[start, end)`` to a score snippet held by a source."""
80
+ body = {"source": source, "id": id, "url": url, "format": format}
81
+ return Annotation("score", start, end, body)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Line:
86
+ """One line of a song's text: ``text == song.text[start:end]``."""
87
+
88
+ start: int
89
+ end: int
90
+ text: str
91
+
92
+
93
+ @dataclass
94
+ class Song:
95
+ """Lyrics text plus annotations, metadata and provenance.
96
+
97
+ Args:
98
+ text: The lyrics, lines separated by ``"\\n"``.
99
+ annotations: Standoff annotations over ``text`` (kept sorted by start).
100
+ meta: Descriptive fields: ``title``, ``artist``, ``capo``, ``key``, ...
101
+ provenance: Where the song came from: ``source``, ``id``, ``url``, ``license``.
102
+ """
103
+
104
+ text: str
105
+ annotations: list[Annotation] = field(default_factory=list)
106
+ meta: dict = field(default_factory=dict)
107
+ provenance: dict = field(default_factory=dict)
108
+
109
+ def __post_init__(self):
110
+ for annotation in self.annotations:
111
+ if annotation.end > len(self.text):
112
+ raise ValueError(
113
+ f"{annotation} ends past the text (length {len(self.text)})"
114
+ )
115
+ # A stable sort: annotations sharing an offset keep their given order.
116
+ self.annotations = sorted(self.annotations, key=lambda a: a.start)
117
+
118
+ @property
119
+ def title(self) -> str:
120
+ return self.meta.get("title", "")
121
+
122
+ @property
123
+ def artist(self) -> str:
124
+ return self.meta.get("artist", "")
125
+
126
+ def of_kind(self, kind: str) -> list[Annotation]:
127
+ """The annotations of one kind, in text order."""
128
+ return [a for a in self.annotations if a.kind == kind]
129
+
130
+ def lines(self) -> Iterator[Line]:
131
+ """The lines of the text, with their offsets."""
132
+ start = 0
133
+ for text in self.text.split("\n"):
134
+ yield Line(start, start + len(text), text)
135
+ start += len(text) + 1
136
+
137
+ def to_dict(self) -> dict:
138
+ """The JSON-ready form (what the store persists)."""
139
+ return {
140
+ "text": self.text,
141
+ "annotations": [a.to_dict() for a in self.annotations],
142
+ "meta": dict(self.meta),
143
+ "provenance": dict(self.provenance),
144
+ }
145
+
146
+ @classmethod
147
+ def from_dict(cls, data: Mapping) -> Song:
148
+ """The inverse of :meth:`to_dict`."""
149
+ return cls(
150
+ text=data["text"],
151
+ annotations=[Annotation(**a) for a in data.get("annotations", ())],
152
+ meta=dict(data.get("meta", {})),
153
+ provenance=dict(data.get("provenance", {})),
154
+ )
songleaf/parse.py ADDED
@@ -0,0 +1,257 @@
1
+ """Parse chords-over-lyrics text into a :class:`~songleaf.model.Song`.
2
+
3
+ This is the format most chord sites use, and the one in the Kaggle corpus: a
4
+ line of chord symbols, column-aligned over the lyric line it belongs to.
5
+
6
+ - Each chord is anchored on the syllable under it (a chord over a space moves
7
+ to the start of the next syllable).
8
+ - A chord line with no lyric line under it (an intro, a solo) becomes an empty
9
+ text line carrying its chords in order.
10
+ - Section headers (``Verse 1:``, ``[Chorus]``, ``Intro 2x: G D``) become
11
+ section annotations; a header with no lines of its own is a *marker*
12
+ (``{"marker": True}``), usually meaning "play that section again".
13
+ - ``Capo``, ``Key``/``Tom`` and ``Tuning`` lines become metadata.
14
+ - Tablature and decoration lines (``e|--3--|``, ``=====``) are dropped.
15
+
16
+ >>> song = parse_chords_over_lyrics("Verse 1:\\nC G\\nHello there my friend")
17
+ >>> song.text
18
+ 'Hello there my friend'
19
+ >>> [(a.start, a.body["symbol"]) for a in song.of_kind("chord")]
20
+ [(0, 'C'), (8, 'G')]
21
+ >>> song.of_kind("section")[0].body["label"]
22
+ 'Verse 1'
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from collections.abc import Mapping
29
+
30
+ from songleaf.model import Annotation, Song, chord, section
31
+
32
+ _CHORD_RE = re.compile(
33
+ r"[*.]{0,3}\(?" # leading marks: *D, ...Dm6
34
+ r"[A-G][#b]?" # root
35
+ r"(?:maj|min|dim|aug|sus|add|m|M|\+|°|º|ø)?" # quality
36
+ r"\d{0,2}[M+\-]?" # extension: 7, 9, 13; Brazilian 7M, 7+, 5-
37
+ r"(?:sus\d{0,2}|(?:maj|add|dim|aug|no|m|b|#|\+|-)\d{1,2}[+\-]?)*" # b5, sus, add9
38
+ r"(?:/[#b]?\d{1,2}[+\-#b]?)*" # stacked extensions: 7/9, 4/7, 5-/7, /b13, 7/9b
39
+ r"(?:\([^()\s]{1,10}\))?" # parenthesised alterations: (#9), (4/9), (b5)
40
+ r"(?:/[A-G][#b]?)?" # bass
41
+ r"\)?\*?"
42
+ )
43
+ # Tokens allowed on a chord line besides chords: bars, repeats, "no chord", stray
44
+ # marks, and short notes such as "(riff)" or "(walking bass)".
45
+ _FILLER_RE = re.compile(
46
+ r"\|+:?|:?\|+|-+|/+|%|\.+|\*+|[()?]|x|\d{1,2}"
47
+ r"|\(?(?:x\s*\d+|\d+\s*x)\)?|N\.?C\.?|\(\w{1,12}\)?|\w{1,12}\)",
48
+ re.IGNORECASE,
49
+ )
50
+ _SECTION_WORDS = (
51
+ r"(?:pr[eé]-?|post-?)?"
52
+ r"(?:intro\w*|intr\.|verses?|verso|estrofa|chorus|refr[aã]o|coro|estribillo|bridge|ponte"
53
+ r"|puente|outro|final|solo|interlude|instrumental|refrain|hook|coda|ending|break"
54
+ r"|tag|riff)"
55
+ )
56
+ _HEADER_PLAIN_RE = re.compile(
57
+ rf"^\s*(?P<label>{_SECTION_WORDS}(?:\s*\d+)?"
58
+ r"(?:\s*\(?\s*(?:x\s*\d+|\d+\s*x)\s*\)?)?" # repeats: x2, 2x, (x2)
59
+ r"(?:\s*\([^)]{1,20}\))?)" # a note: (repeat)
60
+ r"\s*(?::\s*(?P<rest>.*))?$",
61
+ re.IGNORECASE,
62
+ )
63
+ _HEADER_BRACKET_RE = re.compile(
64
+ rf"^\s*\[\s*(?P<label>{_SECTION_WORDS}[^\]]*)\]\s*(?P<rest>.*)$", re.IGNORECASE
65
+ )
66
+ # "Intro G D Em": a section word followed directly by its chords, no colon.
67
+ _HEADER_INLINE_RE = re.compile(
68
+ rf"^\s*(?P<label>{_SECTION_WORDS}(?:\s*\d+)?)\s*[:.]?\s+(?P<rest>\S.*)$",
69
+ re.IGNORECASE,
70
+ )
71
+ _CAPO_RE = re.compile(r"^\s*capo\b\D*(\d+)", re.IGNORECASE)
72
+ _NO_CAPO_RE = re.compile(r"^\s*(?:no|sem|sin)\s+capo\b.*$", re.IGNORECASE)
73
+ _META_RE = re.compile(
74
+ r"^\s*(?P<field>tuning|afina[cç][aã]o|afinaci[oó]n|key|tom|tono)\s*:\s*(?P<value>.+)$",
75
+ re.IGNORECASE,
76
+ )
77
+ _META_FIELDS = {"tom": "key", "tono": "key", "key": "key"}
78
+ _TAB_RE = re.compile(r"[A-Ga-g]?[#b]?\s*[|:]?[-0-9hpbrvx/\\~|*.()\s]+")
79
+ _DECORATION_RE = re.compile(r"[=_\-~*.#^\s]{3,}")
80
+
81
+
82
+ def is_chord_token(token: str) -> bool:
83
+ """True if ``token`` reads as a chord symbol (``G``, ``F#m7b5``, ``E7(#9)``, ``C/G``)."""
84
+ return bool(_CHORD_RE.fullmatch(token))
85
+
86
+
87
+ def is_chord_line(line: str) -> bool:
88
+ """True if every token is a chord or chord-line filler, and at least one is a chord.
89
+
90
+ >>> is_chord_line("G D/F# Em (x2)")
91
+ True
92
+ >>> is_chord_line("Bad Dad")
93
+ False
94
+ """
95
+ tokens = line.split()
96
+ return any(is_chord_token(t) for t in tokens) and all(
97
+ is_chord_token(t) or _FILLER_RE.fullmatch(t) for t in tokens
98
+ )
99
+
100
+
101
+ def _is_tab_line(line: str) -> bool:
102
+ stripped = line.strip()
103
+ if _DECORATION_RE.fullmatch(stripped):
104
+ return True
105
+ looks_like_tab = "|" in stripped or any(ch.isdigit() for ch in stripped)
106
+ return (
107
+ stripped.count("-") >= 4
108
+ and looks_like_tab
109
+ and bool(_TAB_RE.fullmatch(stripped))
110
+ )
111
+
112
+
113
+ def _header(line: str) -> tuple[str, str] | None:
114
+ match = _HEADER_BRACKET_RE.match(line) or _HEADER_PLAIN_RE.match(line)
115
+ if not match:
116
+ match = _HEADER_INLINE_RE.match(line)
117
+ if not (match and is_chord_line(match.group("rest"))):
118
+ return None
119
+ label = " ".join(match.group("label").split()).strip(" :")
120
+ rest = (match.group("rest") or "").strip()
121
+ if rest and all(_FILLER_RE.fullmatch(token) for token in rest.split()):
122
+ return f"{label} {rest}", "" # "Chorus: x2"
123
+ return label, rest
124
+
125
+
126
+ def _kind(line: str) -> str:
127
+ if not line.strip():
128
+ return "blank"
129
+ if _CAPO_RE.match(line) or _NO_CAPO_RE.match(line) or _META_RE.match(line):
130
+ return "meta"
131
+ if _is_tab_line(line):
132
+ return "tab"
133
+ if _header(line):
134
+ return "header"
135
+ if is_chord_line(line):
136
+ return "chords"
137
+ return "lyric"
138
+
139
+
140
+ def _meta_of(line: str) -> dict:
141
+ if capo := _CAPO_RE.match(line):
142
+ return {"capo": int(capo.group(1))}
143
+ if field := _META_RE.match(line):
144
+ name = field.group("field").lower()
145
+ return {_META_FIELDS.get(name, "tuning"): field.group("value").strip()}
146
+ return {}
147
+
148
+
149
+ def _anchored_chords(chord_line: str, lyric_line: str) -> tuple[str, list]:
150
+ """The stripped lyric text, and its chords as ``(offset, symbol)``."""
151
+ indent = len(lyric_line) - len(lyric_line.lstrip())
152
+ text = lyric_line.strip()
153
+ chords = []
154
+ for match in re.finditer(r"\S+", chord_line):
155
+ if not is_chord_token(match.group()):
156
+ continue
157
+ offset = min(max(match.start() - indent, 0), len(text))
158
+ while offset < len(text) and text[offset] == " ":
159
+ offset += 1
160
+ chords.append((offset, match.group()))
161
+ return text, chords
162
+
163
+
164
+ def _bare_chords(line: str) -> list:
165
+ return [(0, token) for token in line.split() if is_chord_token(token)]
166
+
167
+
168
+ def parse_chords_over_lyrics(
169
+ raw: str, *, meta: Mapping | None = None, provenance: Mapping | None = None
170
+ ) -> Song:
171
+ """Parse chords-over-lyrics text into a :class:`Song`.
172
+
173
+ Args:
174
+ raw: The chord chart, chord lines column-aligned over lyric lines.
175
+ meta: Metadata to start from (``title``, ``artist``, ...); ``capo``,
176
+ ``key`` and ``tuning`` found in the text are added to it.
177
+ provenance: Where the text came from, stored as is.
178
+ """
179
+ raw_lines = [line.expandtabs(8).rstrip() for line in re.split(r"\r\n|\r|\n", raw)]
180
+ lines: list[tuple[str, list]] = [] # (text, [(offset, symbol)])
181
+ sections: list[tuple[str, int, bool]] = [] # (label, line index, marker)
182
+ markers: set[int] = set() # empty lines that carry a marker section
183
+ pending = None # a header's label, waiting for the first line of its section
184
+ meta = dict(meta or {})
185
+
186
+ def is_blank(index):
187
+ text, chords = lines[index]
188
+ return not text and not chords and index not in markers
189
+
190
+ def add(text, chords):
191
+ nonlocal pending
192
+ if pending is not None:
193
+ sections.append((pending, len(lines), False))
194
+ pending = None
195
+ lines.append((text, chords))
196
+
197
+ def close_pending():
198
+ # A header with no lines before the next header or the end is a marker
199
+ # ("[Chorus]": play the chorus again), on an empty line of its own.
200
+ nonlocal pending
201
+ if pending is not None:
202
+ sections.append((pending, len(lines), True))
203
+ markers.add(len(lines))
204
+ lines.append(("", []))
205
+ pending = None
206
+
207
+ i = 0
208
+ while i < len(raw_lines):
209
+ line = raw_lines[i]
210
+ kind = _kind(line)
211
+ if kind == "blank":
212
+ if pending is None and lines and not is_blank(len(lines) - 1):
213
+ lines.append(("", []))
214
+ elif kind == "meta":
215
+ meta.update(_meta_of(line))
216
+ elif kind == "header":
217
+ close_pending()
218
+ pending, rest = _header(line)
219
+ if rest:
220
+ add(*(("", _bare_chords(rest)) if is_chord_line(rest) else (rest, [])))
221
+ elif kind == "chords":
222
+ if i + 1 < len(raw_lines) and _kind(raw_lines[i + 1]) == "lyric":
223
+ add(*_anchored_chords(line, raw_lines[i + 1]))
224
+ i += 2
225
+ continue
226
+ add("", _bare_chords(line))
227
+ elif kind == "lyric":
228
+ add(line.strip(), [])
229
+ i += 1
230
+ close_pending()
231
+
232
+ while lines and is_blank(len(lines) - 1):
233
+ lines.pop()
234
+
235
+ starts, offset = [], 0
236
+ for text, _ in lines:
237
+ starts.append(offset)
238
+ offset += len(text) + 1
239
+ text = "\n".join(text for text, _ in lines)
240
+
241
+ annotations: list[Annotation] = [
242
+ chord(starts[index] + at, symbol)
243
+ for index, (_, chords) in enumerate(lines)
244
+ for at, symbol in chords
245
+ ]
246
+ for k, (label, index, marker) in enumerate(sections):
247
+ start = starts[index]
248
+ if marker:
249
+ annotations.append(
250
+ Annotation("section", start, start, {"label": label, "marker": True})
251
+ )
252
+ continue
253
+ last = (sections[k + 1][1] if k + 1 < len(sections) else len(lines)) - 1
254
+ while last > index and is_blank(last):
255
+ last -= 1
256
+ annotations.append(section(start, starts[last] + len(lines[last][0]), label))
257
+ return Song(text, annotations, meta=meta, provenance=dict(provenance or {}))