songscan 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.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: songscan
3
+ Version: 0.1.0
4
+ Summary: Read songs out of PDFs: chord charts as ChordPro, sheet music as MusicXML
5
+ Author: Jared McKnight
6
+ Author-email: Jared McKnight <jmcknight@archertech.ca>
7
+ License-Expression: MIT
8
+ Requires-Dist: anthropic
9
+ Requires-Dist: chordpro-renderer
10
+ Requires-Dist: pdfplumber
11
+ Requires-Dist: pydantic>=2
12
+ Requires-Python: >=3.13
13
+ Project-URL: Homepage, https://github.com/jnhmcknight/songscan
14
+ Project-URL: Repository, https://github.com/jnhmcknight/songscan
15
+ Project-URL: Issues, https://github.com/jnhmcknight/songscan/issues
16
+ Description-Content-Type: text/markdown
17
+
18
+ # songscan
19
+
20
+ Read songs out of PDFs. A chord chart becomes [ChordPro](https://www.chordpro.org/), with the
21
+ song's details (title, artists, key, tempo, time, capo, copyright) alongside; sheet music will
22
+ become MusicXML.
23
+
24
+ A PDF's own text is read first: charts typed up in a word processor or chart app keep their words
25
+ and where each sits on the page, so their chords can be put back over the syllables they are
26
+ played on, without asking anyone. Anything else (scans, photos, sheet music, or text that isn't
27
+ plainly a chord chart) is read by Claude, looking at its pages.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ uv add "songscan @ git+https://github.com/jnhmcknight/songscan.git"
33
+ ```
34
+
35
+ Reading scans needs an [Anthropic API key](https://platform.claude.com/), in `ANTHROPIC_API_KEY`
36
+ or a client you pass in. PDFs with their own text don't use it.
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from songscan import ScanError, scan_pdf
42
+
43
+ try:
44
+ result = scan_pdf(pdf_bytes)
45
+ except ScanError as error:
46
+ ... # error.retryable: whether trying again later may work
47
+
48
+ result.kind # "chord_chart", "score" or "other"
49
+ result.chordpro # a chord chart's ChordPro, details and all; else None
50
+ result.meta # ScanMeta: title, artists, key, tempo, time, capo, copyright
51
+ result.source # "text" (the PDF's own) or "vision" (Claude's reading)
52
+ result.warnings # anything to check, or that couldn't be done
53
+ ```
54
+
55
+ To choose the client or model:
56
+
57
+ ```python
58
+ import anthropic
59
+
60
+ result = scan_pdf(pdf_bytes, client=anthropic.Anthropic(api_key=...), model="claude-opus-5-5")
61
+ ```
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ uv sync
67
+ uv run pytest
68
+ uv run ruff check && uv run black --check . && uv run pyright
69
+ ```
70
+
71
+ Tests that read PDFs with vision use a stand-in for Claude. To run them against the real API
72
+ instead (which costs a little), set `SONGSCAN_LIVE_API=1` with a key in `ANTHROPIC_API_KEY`:
73
+
74
+ ```bash
75
+ SONGSCAN_LIVE_API=1 uv run pytest
76
+ ```
77
+
78
+ Tests of how songscan handles the API's answers and errors use the stand-in either way.
@@ -0,0 +1,61 @@
1
+ # songscan
2
+
3
+ Read songs out of PDFs. A chord chart becomes [ChordPro](https://www.chordpro.org/), with the
4
+ song's details (title, artists, key, tempo, time, capo, copyright) alongside; sheet music will
5
+ become MusicXML.
6
+
7
+ A PDF's own text is read first: charts typed up in a word processor or chart app keep their words
8
+ and where each sits on the page, so their chords can be put back over the syllables they are
9
+ played on, without asking anyone. Anything else (scans, photos, sheet music, or text that isn't
10
+ plainly a chord chart) is read by Claude, looking at its pages.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ uv add "songscan @ git+https://github.com/jnhmcknight/songscan.git"
16
+ ```
17
+
18
+ Reading scans needs an [Anthropic API key](https://platform.claude.com/), in `ANTHROPIC_API_KEY`
19
+ or a client you pass in. PDFs with their own text don't use it.
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ from songscan import ScanError, scan_pdf
25
+
26
+ try:
27
+ result = scan_pdf(pdf_bytes)
28
+ except ScanError as error:
29
+ ... # error.retryable: whether trying again later may work
30
+
31
+ result.kind # "chord_chart", "score" or "other"
32
+ result.chordpro # a chord chart's ChordPro, details and all; else None
33
+ result.meta # ScanMeta: title, artists, key, tempo, time, capo, copyright
34
+ result.source # "text" (the PDF's own) or "vision" (Claude's reading)
35
+ result.warnings # anything to check, or that couldn't be done
36
+ ```
37
+
38
+ To choose the client or model:
39
+
40
+ ```python
41
+ import anthropic
42
+
43
+ result = scan_pdf(pdf_bytes, client=anthropic.Anthropic(api_key=...), model="claude-opus-5-5")
44
+ ```
45
+
46
+ ## Development
47
+
48
+ ```bash
49
+ uv sync
50
+ uv run pytest
51
+ uv run ruff check && uv run black --check . && uv run pyright
52
+ ```
53
+
54
+ Tests that read PDFs with vision use a stand-in for Claude. To run them against the real API
55
+ instead (which costs a little), set `SONGSCAN_LIVE_API=1` with a key in `ANTHROPIC_API_KEY`:
56
+
57
+ ```bash
58
+ SONGSCAN_LIVE_API=1 uv run pytest
59
+ ```
60
+
61
+ Tests of how songscan handles the API's answers and errors use the stand-in either way.
@@ -0,0 +1,87 @@
1
+ [project]
2
+ name = "songscan"
3
+ version = "0.1.0"
4
+ description = "Read songs out of PDFs: chord charts as ChordPro, sheet music as MusicXML"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.13"
8
+ dependencies = [
9
+ "anthropic",
10
+ "chordpro-renderer",
11
+ "pdfplumber",
12
+ "pydantic>=2",
13
+ ]
14
+
15
+ [[project.authors]]
16
+ name = "Jared McKnight"
17
+ email = "jmcknight@archertech.ca"
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/jnhmcknight/songscan"
21
+ Repository = "https://github.com/jnhmcknight/songscan"
22
+ Issues = "https://github.com/jnhmcknight/songscan/issues"
23
+
24
+ [tool.uv.sources.chordpro-renderer]
25
+ git = "https://github.com/jnhmcknight/chordpro.git"
26
+ rev = "main"
27
+
28
+ [tool.uv.build-backend]
29
+ module-root = ""
30
+ module-name = "songscan"
31
+
32
+ [tool.pytest.ini_options]
33
+ timeout = 15
34
+ testpaths = ["tests"]
35
+ pythonpath = ["."]
36
+ addopts = "--cov=songscan --cov-report=term-missing"
37
+
38
+ [tool.black]
39
+ line-length = 110
40
+
41
+ [tool.ruff]
42
+ line-length = 110
43
+
44
+ [tool.ruff.lint]
45
+ select = [
46
+ "B",
47
+ "C4",
48
+ "FLY",
49
+ "PERF",
50
+ "PIE",
51
+ "PLC0415",
52
+ "RUF",
53
+ "SIM",
54
+ "T20",
55
+ "TC004",
56
+ "UP",
57
+ "F401",
58
+ ]
59
+ ignore = [
60
+ "C408",
61
+ "PERF401",
62
+ ]
63
+
64
+ [tool.ruff.lint.per-file-ignores]
65
+ "tests/*" = ["PLC0415"]
66
+
67
+ [tool.pyright]
68
+ include = ["songscan"]
69
+ useLibraryCodeForTypes = true
70
+ venvPath = "."
71
+ venv = ".venv"
72
+
73
+ [dependency-groups]
74
+ dev = [
75
+ "black>=25.1.0",
76
+ "pyright",
77
+ "pytest>=8.0",
78
+ "pytest-cov>=5.0.0",
79
+ "pytest-randomly>=4.0.1",
80
+ "pytest-timeout>=2.4.0",
81
+ "reportlab>=4.0",
82
+ "ruff",
83
+ ]
84
+
85
+ [build-system]
86
+ requires = ["uv_build>=0.6"]
87
+ build-backend = "uv_build"
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "songscan"
3
+ version = "0.1.0"
4
+ description = "Read songs out of PDFs: chord charts as ChordPro, sheet music as MusicXML"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Jared McKnight", email = "jmcknight@archertech.ca" },
9
+ ]
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "anthropic",
13
+ "chordpro-renderer",
14
+ "pdfplumber",
15
+ "pydantic>=2",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/jnhmcknight/songscan"
20
+ Repository = "https://github.com/jnhmcknight/songscan"
21
+ Issues = "https://github.com/jnhmcknight/songscan/issues"
22
+
23
+ [tool.uv.sources]
24
+ chordpro-renderer = { git = "https://github.com/jnhmcknight/chordpro.git", rev = "main" }
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "black>=25.1.0",
29
+ "pyright",
30
+ "pytest>=8.0",
31
+ "pytest-cov>=5.0.0",
32
+ "pytest-randomly>=4.0.1",
33
+ "pytest-timeout>=2.4.0",
34
+ "reportlab>=4.0",
35
+ "ruff",
36
+ ]
37
+
38
+ [tool.pytest.ini_options]
39
+ timeout = 15
40
+ testpaths = ["tests"]
41
+ pythonpath = ["."]
42
+ addopts = "--cov=songscan --cov-report=term-missing"
43
+
44
+ [build-system]
45
+ requires = ["uv_build>=0.6"]
46
+ build-backend = "uv_build"
47
+
48
+ [tool.uv.build-backend]
49
+ module-root = ""
50
+ module-name = "songscan"
51
+
52
+ [tool.black]
53
+ line-length = 110
54
+
55
+ [tool.ruff]
56
+ line-length = 110
57
+
58
+ [tool.ruff.lint]
59
+ # As the Fanaux backend's: see its pyproject.toml
60
+ select = ["B", "C4", "FLY", "PERF", "PIE", "PLC0415", "RUF", "SIM", "T20", "TC004", "UP", "F401"]
61
+ ignore = ["C408", "PERF401"]
62
+
63
+ [tool.ruff.lint.per-file-ignores]
64
+ "tests/*" = ["PLC0415"]
65
+
66
+ [tool.pyright]
67
+ include = ["songscan"]
68
+ useLibraryCodeForTypes = true
69
+ venvPath = "."
70
+ venv = ".venv"
@@ -0,0 +1,21 @@
1
+ """
2
+ songscan
3
+ ~~~~~~~~
4
+
5
+ Read songs out of PDFs: chord charts become ChordPro, and sheet music
6
+ MusicXML.
7
+
8
+ Usage::
9
+
10
+ from songscan import scan_pdf
11
+
12
+ result = scan_pdf(pdf_bytes)
13
+ if result.kind == "chord_chart":
14
+ save(result.chordpro, key=result.meta.key)
15
+ """
16
+
17
+ from .models import ScanError, ScanMeta, ScanResult
18
+ from .scan import scan_pdf
19
+ from .vision import DEFAULT_MODEL
20
+
21
+ __all__ = ["DEFAULT_MODEL", "ScanError", "ScanMeta", "ScanResult", "scan_pdf"]
@@ -0,0 +1,57 @@
1
+ """What a scan finds in a PDF."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal
5
+
6
+ Kind = Literal["chord_chart", "score", "other"]
7
+
8
+
9
+ @dataclass
10
+ class ScanMeta:
11
+ """The song's details, as far as the PDF shows them."""
12
+
13
+ title: str | None = None
14
+ artists: list[str] = field(default_factory=list)
15
+ key: str | None = None
16
+ tempo: str | None = None
17
+ time: str | None = None
18
+ capo: str | None = None
19
+ copyright: list[str] = field(default_factory=list)
20
+
21
+ def directives(self) -> list[str]:
22
+ """These details as ChordPro directives, for the top of a chart."""
23
+ lines = [f"{{title: {self.title}}}"] if self.title else []
24
+ lines += [f"{{artist: {artist}}}" for artist in self.artists]
25
+ for name in ("key", "tempo", "time", "capo"):
26
+ if value := getattr(self, name):
27
+ lines.append(f"{{{name}: {value}}}")
28
+ lines += [f"{{copyright: {line}}}" for line in self.copyright]
29
+ return lines
30
+
31
+
32
+ @dataclass
33
+ class ScanResult:
34
+ """A PDF, read.
35
+
36
+ A chord chart comes with its ChordPro (details and all), and sheet music
37
+ with its MusicXML; anything else, with neither. `source` says how it was
38
+ read: from the PDF's own text, or by looking at its pages.
39
+ """
40
+
41
+ kind: Kind
42
+ meta: ScanMeta
43
+ pages: int
44
+ source: Literal["text", "vision"]
45
+ chordpro: str | None = None
46
+ musicxml: str | None = None
47
+ # Things the reader should check, or that couldn't be done
48
+ warnings: list[str] = field(default_factory=list)
49
+
50
+
51
+ class ScanError(Exception):
52
+ """The PDF couldn't be read. `retryable` when trying again later may work
53
+ (the vision service was busy or unreachable)."""
54
+
55
+ def __init__(self, message: str, *, retryable: bool = False):
56
+ super().__init__(message)
57
+ self.retryable = retryable
@@ -0,0 +1,22 @@
1
+ """Scanning a PDF: its own text first, which is free and exact, and Claude's
2
+ reading of its pages when that isn't plainly a chord chart."""
3
+
4
+ import anthropic
5
+
6
+ from .models import ScanResult
7
+ from .text import chart_from_text, read_text
8
+ from .vision import DEFAULT_MODEL, read_with_vision
9
+
10
+
11
+ def scan_pdf(
12
+ data: bytes, *, client: anthropic.Anthropic | None = None, model: str = DEFAULT_MODEL
13
+ ) -> ScanResult:
14
+ """What the PDF *data* holds: a chord chart (as ChordPro), sheet music, or
15
+ something else. *client* reads scans and sheet music; by default, one
16
+ with the environment's API key. Raises ScanError when it can't be read.
17
+ """
18
+ text = read_text(data)
19
+ if text.has_text and not text.music_font and (chart := chart_from_text(text.lines)):
20
+ chordpro, meta = chart
21
+ return ScanResult(kind="chord_chart", meta=meta, pages=text.pages, source="text", chordpro=chordpro)
22
+ return read_with_vision(data, text.pages, client or anthropic.Anthropic(), model)
@@ -0,0 +1,268 @@
1
+ """Reading a PDF's own text: for charts typed up in a word processor or chart
2
+ app, which keep their words (and where on the page each one is). Their lines
3
+ are rebuilt from where the words sit, so that chords stay above the syllables
4
+ they are played on, and a chord chart is turned into ChordPro without asking
5
+ anyone.
6
+ """
7
+
8
+ import io
9
+ import itertools
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from statistics import median
13
+
14
+ import pdfplumber
15
+ from pdfminer.pdfparser import PDFSyntaxError
16
+ from pdfplumber.page import Page
17
+ from pdfplumber.utils.exceptions import PdfminerException
18
+
19
+ from chordpro.chordsheet import chord_line, chord_sheet_to_chordpro, section_kind
20
+
21
+ from .models import ScanError, ScanMeta
22
+
23
+ # Below this many characters, a PDF's text is too little to be the song: it
24
+ # is a scan or a picture, perhaps with a page number or a footer
25
+ MIN_TEXT = 40
26
+
27
+ # Fonts that notation software draws notes with: a PDF using one is sheet music
28
+ MUSIC_FONTS = re.compile(
29
+ r"bravura|leland|petaluma|emmentaler|feta|gonville|maestro|petrucci|engraver|"
30
+ r"sebastian|opus|mscore|musisync|november|finale|lilyjazz|musejazz",
31
+ re.IGNORECASE,
32
+ )
33
+
34
+ # A chart needs at least this many lines of chords, making up at least this
35
+ # share of its lines
36
+ MIN_CHORD_LINES = 2
37
+ MIN_CHORD_SHARE = 0.15
38
+
39
+ _KEY_RE = re.compile(r"\bkey\s*(?:of|[:=-])?\s*([A-G][#b♯♭]?m?)\b", re.IGNORECASE)
40
+ _TEMPO_RE = re.compile(r"\b(?:tempo\s*[:=-]?\s*(\d{2,3})|(\d{2,3})\s*bpm)\b", re.IGNORECASE)
41
+ _TIME_RE = re.compile(r"\b(?:time(?:\s*signature)?\s*[:=-]?\s*)?(\d{1,2}/\d{1,2})\b", re.IGNORECASE)
42
+ _CAPO_RE = re.compile(r"\bcapo\s*[:=-]?\s*(\d{1,2})\b", re.IGNORECASE)
43
+ _COPYRIGHT_RE = re.compile(r"©|\(c\)|\bcopyright\b|\bccli\b|\ball rights reserved\b", re.IGNORECASE)
44
+ # Between a header's columns
45
+ _COLUMN_GAP = re.compile(r"\s{4,}")
46
+ _BY_RE = re.compile(r"^\s*(?:words\s+and\s+music\s+)?by\s+", re.IGNORECASE)
47
+ # A section label, once tidied: "Verse 1", "Pre-Chorus 2", "Bridge"
48
+ _SECTION_RE = re.compile(r"[A-Za-z][A-Za-z-]*(?: [A-Za-z][A-Za-z-]*)*(?: ?\d+)?")
49
+ # What may follow a label: "Chorus (x2)", "Chorus x2"
50
+ _REPEAT_RE = re.compile(r"\(? ?x ?\d+ ?\)?$", re.IGNORECASE)
51
+ # Most a label can be: "Pre-Chorus 2", not a lyric that starts with "Bridge"
52
+ _SECTION_WORDS = 3
53
+
54
+
55
+ @dataclass
56
+ class PdfText:
57
+ """A PDF's text, as lines laid out the way they are on its pages."""
58
+
59
+ pages: int
60
+ lines: list[str] = field(default_factory=list)
61
+ # Characters of text it has, and whether it draws notes in a music font
62
+ characters: int = 0
63
+ music_font: bool = False
64
+
65
+ @property
66
+ def has_text(self) -> bool:
67
+ return self.characters >= MIN_TEXT
68
+
69
+
70
+ def read_text(data: bytes) -> PdfText:
71
+ """The text of the PDF *data*."""
72
+ try:
73
+ with pdfplumber.open(io.BytesIO(data)) as pdf:
74
+ text = PdfText(pages=len(pdf.pages))
75
+ for page in pdf.pages:
76
+ if text.lines:
77
+ text.lines.append("")
78
+ text.lines.extend(page_lines(page))
79
+ text.characters += sum(1 for char in page.chars if not char["text"].isspace())
80
+ text.music_font = text.music_font or any(
81
+ MUSIC_FONTS.search(c["fontname"]) for c in page.chars
82
+ )
83
+ except (PdfminerException, PDFSyntaxError, ValueError) as error:
84
+ raise ScanError(f"This isn't a PDF that can be read: {error}") from error
85
+ return text
86
+
87
+
88
+ def page_lines(page: Page) -> list[str]:
89
+ """The page's lines of text, each word at the column it starts at, and a
90
+ blank line where there is a gap between lines (as between sections)."""
91
+ words = page.extract_words(use_text_flow=False)
92
+ if not words:
93
+ return []
94
+ char_width = median((w["x1"] - w["x0"]) / len(w["text"]) for w in words)
95
+ height = median(w["bottom"] - w["top"] for w in words)
96
+ left = min(w["x0"] for w in words)
97
+
98
+ # Words on the same line (their tops within half a line of each other)
99
+ rows: list[list[dict]] = []
100
+ for word in sorted(words, key=lambda w: (w["top"], w["x0"])):
101
+ if rows and word["top"] - rows[-1][0]["top"] <= height / 2:
102
+ rows[-1].append(word)
103
+ else:
104
+ rows.append([word])
105
+
106
+ rows = [sorted(row, key=lambda w: w["x0"]) for row in rows]
107
+ tops = [row[0]["top"] for row in rows]
108
+ gaps = [b - a for a, b in itertools.pairwise(tops)]
109
+ # Lines further apart than this have a blank line between them
110
+ blank_gap = median(gaps) * 1.6 if gaps else 0
111
+
112
+ placed = [_columns(row, left, char_width) for row in rows]
113
+ texts = [_line(columns) for columns in placed]
114
+ # Chords go over the syllables they sit above, wherever the lyric's
115
+ # (proportional) letters put those in the line
116
+ for index in range(len(rows) - 1):
117
+ if (
118
+ gaps[index] <= blank_gap
119
+ and chord_line(texts[index]) is not None
120
+ and chord_line(texts[index + 1]) is None
121
+ ):
122
+ placed[index] = _over(rows[index], placed[index + 1], left, char_width)
123
+ texts[index] = _line(placed[index])
124
+
125
+ lines = []
126
+ for index, text in enumerate(texts):
127
+ if index and gaps[index - 1] > blank_gap:
128
+ lines.append("")
129
+ lines.append(text)
130
+ return lines
131
+
132
+
133
+ def _columns(row: list[dict], left: float, char_width: float) -> list[tuple[int, dict]]:
134
+ """Each of a line's words, with the column it starts at."""
135
+ columns: list[tuple[int, dict]] = []
136
+ end = -1 # where the word before ends, and a space after it
137
+ before = None
138
+ for word in row:
139
+ own_width = (word["x1"] - word["x0"]) / len(word["text"])
140
+ if before is not None and word["x0"] - before["x1"] < own_width * 1.5:
141
+ column = end + 1 # an ordinary space, however large the font
142
+ else:
143
+ column = max(round((word["x0"] - left) / char_width), end + 1)
144
+ columns.append((column, word))
145
+ end = column + len(word["text"])
146
+ before = word
147
+ return columns
148
+
149
+
150
+ def _over(chords: list[dict], lyric: list[tuple[int, dict]], left: float, char_width: float):
151
+ """The columns of a line of *chords* over a *lyric*: each over the letter
152
+ of the lyric's word that it starts above."""
153
+ columns: list[tuple[int, dict]] = []
154
+ end = -1
155
+ for chord in chords:
156
+ column = round((chord["x0"] - left) / char_width)
157
+ for start, word in lyric:
158
+ width = (word["x1"] - word["x0"]) / len(word["text"])
159
+ if word["x0"] - width / 2 <= chord["x0"] < word["x1"]:
160
+ column = start + min(round((chord["x0"] - word["x0"]) / width), len(word["text"]) - 1)
161
+ break
162
+ if chord["x0"] >= word["x1"]:
163
+ # Past this word: as far past its end as the chord is
164
+ column = start + len(word["text"]) + max(round((chord["x0"] - word["x1"]) / char_width), 1)
165
+ column = max(column, end + 1)
166
+ columns.append((column, chord))
167
+ end = column + len(chord["text"])
168
+ return columns
169
+
170
+
171
+ def _line(columns: list[tuple[int, dict]]) -> str:
172
+ line = ""
173
+ for column, word in columns:
174
+ line = line.ljust(column) + word["text"]
175
+ return line
176
+
177
+
178
+ def section(line: str) -> tuple[str, str] | None:
179
+ """The ChordPro section and label a chart's line opens, if it is a label."""
180
+ label = " ".join(line.split()) # one space between words
181
+ label = _REPEAT_RE.sub("", label).strip()
182
+ label = label.removeprefix("[").removesuffix(":").removesuffix("]").strip()
183
+ if len(label.split()) > _SECTION_WORDS or not _SECTION_RE.fullmatch(label):
184
+ return None
185
+ kind = section_kind(label)
186
+ if kind is None:
187
+ return None
188
+ # Labels in capitals read as shouting in ChordPro's: "VERSE 1" → "Verse 1"
189
+ return kind, label.title() if label.isupper() else label
190
+
191
+
192
+ def is_chord_chart(lines: list[str]) -> bool:
193
+ """Whether *lines* are a chord chart: lines of chords, over lyrics or not."""
194
+ text = [line for line in lines if line.strip()]
195
+ chords = sum(1 for line in text if chord_line(line) is not None)
196
+ return chords >= MIN_CHORD_LINES and chords / len(text) >= MIN_CHORD_SHARE
197
+
198
+
199
+ def chart_from_text(lines: list[str]) -> tuple[str, ScanMeta] | None:
200
+ """The ChordPro, and the details, of a chord chart's lines; None when they
201
+ aren't a chord chart."""
202
+ if not is_chord_chart(lines):
203
+ return None
204
+ meta = ScanMeta()
205
+
206
+ # Copyright lines may be anywhere, often at the foot of the page
207
+ body = []
208
+ for line in lines:
209
+ if _COPYRIGHT_RE.search(line):
210
+ meta.copyright.append(line.strip())
211
+ else:
212
+ body.append(line)
213
+
214
+ # The details are at the top, before the first chords or section label
215
+ start = next(
216
+ (i for i, line in enumerate(body) if chord_line(line) is not None or section(line)),
217
+ len(body),
218
+ )
219
+ # Often in two columns, the title on the left and the details on the right
220
+ aside = []
221
+ for line in body[:start]:
222
+ left, *right = _COLUMN_GAP.split(line.strip())
223
+ aside.extend(right)
224
+ if not left or _details(left, meta):
225
+ continue
226
+ if meta.title is None:
227
+ meta.title = left
228
+ elif not meta.artists:
229
+ meta.artists = [_BY_RE.sub("", left)]
230
+ _details(" ".join(aside), meta, whole_line=False)
231
+ # The details may also share a line with the first chords' label
232
+ for line in body[start : start + 3]:
233
+ _details(line, meta, whole_line=False)
234
+
235
+ content = _unindent(chord_sheet_to_chordpro(body[start:], section))
236
+ return "\n".join([*meta.directives(), "", *content]), meta
237
+
238
+
239
+ def _unindent(chordpro: list[str]) -> list[str]:
240
+ """*chordpro* without the margins of the page it came from. Its chords
241
+ are in its lyrics, so this can't move them; tabs keep their spacing."""
242
+ lines, tab = [], False
243
+ for line in chordpro:
244
+ if line.startswith("{start_of_tab"):
245
+ tab = True
246
+ elif line.startswith("{end_of_tab"):
247
+ tab = False
248
+ lines.append(line if tab else line.strip())
249
+ return lines
250
+
251
+
252
+ def _details(line: str, meta: ScanMeta, whole_line: bool = True) -> bool:
253
+ """Fill in *meta* with the key, tempo, time or capo *line* gives; whether
254
+ it gave any (and, with *whole_line*, was nothing but those)."""
255
+ found = False
256
+ for pattern, name in ((_KEY_RE, "key"), (_TEMPO_RE, "tempo"), (_CAPO_RE, "capo")):
257
+ if (m := pattern.search(line)) and getattr(meta, name) is None:
258
+ setattr(meta, name, next(g for g in m.groups() if g))
259
+ found = True
260
+ if (m := _TIME_RE.search(line)) and meta.time is None and (found or "time" in line.lower() or whole_line):
261
+ meta.time = m.group(1)
262
+ found = True
263
+ if not whole_line:
264
+ return found
265
+ rest = line
266
+ for pattern in (_KEY_RE, _TEMPO_RE, _TIME_RE, _CAPO_RE):
267
+ rest = pattern.sub("", rest)
268
+ return found and not re.sub(r"[\s|,;·•/-]+", "", rest)
@@ -0,0 +1,118 @@
1
+ """Reading a PDF by looking at its pages, with Claude: for scans and photos of
2
+ charts, which have no text of their own, and to tell what a PDF is when its
3
+ text isn't plainly a chord chart.
4
+ """
5
+
6
+ import base64
7
+ from typing import Literal
8
+
9
+ import anthropic
10
+ from anthropic.types.beta import BetaMessageParam, BetaRequestDocumentBlockParam
11
+ from pydantic import BaseModel, Field
12
+
13
+ from .models import ScanError, ScanMeta, ScanResult
14
+
15
+ DEFAULT_MODEL = "claude-opus-5-5"
16
+
17
+ # Falls back to another model, picked by why, should the model decline
18
+ _FALLBACK_BETA = "server-side-fallback-2026-07-01"
19
+
20
+ SYSTEM = """\
21
+ You read PDFs of songs for a church worship team's song library, and turn each into data the library \
22
+ can store. Musicians will play from what you write, so accuracy matters more than completeness.
23
+
24
+ First, decide what the PDF is:
25
+ - chord_chart: lyrics with chord names (above the words, or inline), or a chart of chords alone.
26
+ - score: sheet music, with notes on staves: lead sheets, piano/vocal, choral or instrumental parts.
27
+ - other: anything else, such as lyrics without chords.
28
+
29
+ For a chord_chart, write the song in ChordPro as `chordpro`:
30
+ - Put each chord in square brackets just before the syllable it is played on: "[G]Amazing [C]grace". \
31
+ Use the chord names exactly as written.
32
+ - Wrap each section in a start and end directive with the chart's own label, such as \
33
+ "{start_of_verse: Verse 1}" ... "{end_of_verse}". The kinds are verse, chorus, prechorus, bridge, \
34
+ intro, outro, tag, interlude and instrumental.
35
+ - A line of chords with no lyrics stays a line of bracketed chords: "[G] [C] [D]".
36
+ - Instructions to the band, such as "Repeat x2" or "Build", become "{comment: Repeat x2}".
37
+ - Copy what the PDF shows, as it shows it: don't correct, complete, transpose or reorder anything, \
38
+ and write out a section again only where the PDF does.
39
+ - Leave the title, artists, key, tempo, time signature, capo and copyright out of `chordpro`: \
40
+ they have fields of their own.
41
+ For a score or other, `chordpro` is null.
42
+
43
+ Fill in the song's details only from what the PDF shows, and null (or an empty list) for anything \
44
+ it doesn't: the title, the artists or writers, the key (like "G", "B♭" or "F♯m"), the tempo in beats \
45
+ per minute (digits only), the time signature (like "4/4"), the capo fret (digits only), and the \
46
+ copyright lines, one per line, including any CCLI song number.
47
+ """
48
+
49
+
50
+ class _Song(BaseModel):
51
+ """What the model makes of a PDF."""
52
+
53
+ kind: Literal["chord_chart", "score", "other"]
54
+ title: str | None
55
+ artists: list[str]
56
+ key: str | None
57
+ tempo: str | None
58
+ time: str | None
59
+ capo: str | None
60
+ copyright: list[str]
61
+ chordpro: str | None = Field(description="The chord chart in ChordPro, without its details.")
62
+
63
+
64
+ def read_with_vision(data: bytes, pages: int, client: anthropic.Anthropic, model: str) -> ScanResult:
65
+ """What Claude makes of the PDF *data* (of *pages* pages)."""
66
+ document: BetaRequestDocumentBlockParam = {
67
+ "type": "document",
68
+ "source": {
69
+ "type": "base64",
70
+ "media_type": "application/pdf",
71
+ "data": base64.b64encode(data).decode(),
72
+ },
73
+ }
74
+ message: BetaMessageParam = {
75
+ "role": "user",
76
+ "content": [document, {"type": "text", "text": "Read this PDF."}],
77
+ }
78
+ try:
79
+ response = client.beta.messages.parse(
80
+ model=model,
81
+ max_tokens=16000,
82
+ system=SYSTEM,
83
+ messages=[message],
84
+ output_format=_Song,
85
+ output_config={"effort": "medium"},
86
+ fallbacks="default",
87
+ betas=[_FALLBACK_BETA],
88
+ )
89
+ except (anthropic.RateLimitError, anthropic.InternalServerError, anthropic.APIConnectionError) as error:
90
+ raise ScanError(f"The PDF couldn't be read just now: {error}", retryable=True) from error
91
+ except anthropic.APIStatusError as error:
92
+ raise ScanError(f"The PDF couldn't be read: {error.message}") from error
93
+
94
+ if response.stop_reason == "refusal":
95
+ raise ScanError("The PDF couldn't be read: reading it was declined.")
96
+ if response.stop_reason == "max_tokens":
97
+ raise ScanError("The PDF couldn't be read: it is too long.")
98
+ song = response.parsed_output
99
+ if song is None:
100
+ raise ScanError("The PDF couldn't be read: no song came back.")
101
+
102
+ meta = ScanMeta(
103
+ title=song.title,
104
+ artists=song.artists,
105
+ key=song.key,
106
+ tempo=song.tempo,
107
+ time=song.time,
108
+ capo=song.capo,
109
+ copyright=song.copyright,
110
+ )
111
+ result = ScanResult(kind=song.kind, meta=meta, pages=pages, source="vision")
112
+ if song.kind == "chord_chart" and song.chordpro:
113
+ result.chordpro = "\n".join([*meta.directives(), "", song.chordpro.strip()])
114
+ elif song.kind == "chord_chart":
115
+ result.warnings.append("No chords or lyrics could be read from this chart.")
116
+ elif song.kind == "score":
117
+ result.warnings.append("Sheet music isn't read into MusicXML yet.")
118
+ return result