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 +43 -0
- songleaf/__main__.py +55 -0
- songleaf/model.py +154 -0
- songleaf/parse.py +257 -0
- songleaf/render.py +506 -0
- songleaf/sources.py +261 -0
- songleaf/store.py +97 -0
- songleaf/tools.py +115 -0
- songleaf-0.0.2.dist-info/METADATA +119 -0
- songleaf-0.0.2.dist-info/RECORD +13 -0
- songleaf-0.0.2.dist-info/WHEEL +4 -0
- songleaf-0.0.2.dist-info/entry_points.txt +2 -0
- songleaf-0.0.2.dist-info/licenses/LICENSE +21 -0
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 {}))
|