Metadata-Version: 2.5
Name: songleaf
Version: 0.0.29
Summary: Dense, readable one-page song sheets (lyrics + chords + score snippets) from searchable sources
Project-URL: Homepage, https://github.com/thorwhalen/songleaf
Project-URL: Repository, https://github.com/thorwhalen/songleaf
Project-URL: Documentation, https://thorwhalen.github.io/songleaf
Author: Thor Whalen
License-Expression: MIT
License-File: LICENSE
License-File: songleaf/data/fonts/LICENSE-DejaVu.txt
Keywords: chords,lead-sheet,lyrics,music,pdf,song-sheet
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: End Users/Desktop
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Multimedia :: Sound/Audio
Requires-Python: >=3.10
Requires-Dist: cw<0.2,>=0.1.4
Requires-Dist: dol
Requires-Dist: pandas
Requires-Dist: pypdf>=4
Requires-Dist: rapidfuzz>=3
Requires-Dist: reportlab>=4
Requires-Dist: scoreseek>=0.0.8
Requires-Dist: sung>=0.0.26
Provides-Extra: dev
Requires-Dist: fastapi>=0.100; extra == 'dev'
Requires-Dist: httpx; extra == 'dev'
Requires-Dist: pillow; extra == 'dev'
Requires-Dist: pypdfium2>=4; extra == 'dev'
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Requires-Dist: svglib; extra == 'dev'
Requires-Dist: verovio; extra == 'dev'
Provides-Extra: docs
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
Requires-Dist: sphinx>=6.0; extra == 'docs'
Provides-Extra: s3
Requires-Dist: s3dol; extra == 's3'
Provides-Extra: score
Requires-Dist: svglib; extra == 'score'
Requires-Dist: verovio; extra == 'score'
Provides-Extra: web
Requires-Dist: fastapi>=0.100; extra == 'web'
Requires-Dist: pillow; extra == 'web'
Requires-Dist: pypdfium2>=4; extra == 'web'
Description-Content-Type: text/markdown

# songleaf

Dense, readable one-page song sheets: lyrics and chords on one A4 page, with the lyrics as large as will fit.

```bash
pip install songleaf
python -m songleaf sheet "wonderwall oasis"    # -> a one-page PDF in ~/.local/share/songleaf/sheets/
```

The default source is the Kaggle *chords-and-lyrics* corpus (about 135,000 songs), read from a local copy of its zip (see [Sources](#sources)).

## What the sheet looks like

- **One page, largest type that fits.** The lyric font size is found by search for each song. On 2,000 randomly sampled corpus songs, the default layout's median came out around 23 pt, and 1,999 of the 2,000 fit on one page. A song that cannot fit at the minimum size spills onto a second page.
- **Any script the font covers.** Text is set in DejaVu Sans, bundled with the package and embedded in each PDF (Bitstream Vera licence, beside the font files), so Turkish, Greek, Cyrillic and other non-Latin-1 text draws correctly. It is wider than Helvetica, so fitted sizes are somewhat smaller than the numbers above, which were measured in Helvetica; `DenseStyle(lyric_font="Helvetica", ...)` restores the old look.
- **Chords take no line of their own.** They are smaller and blue, start over the syllable they land on, and overlap the tops of the letters of their lyric line.
- **Lines share rows.** Consecutive lines of a paragraph are packed onto one row, separated by a light `/`. A blank line or a new section starts a new row.
- **Small labels.** Sections are grey prefixes (`V1`, `Ch`, `Br`); title, artist, capo and key are one small line at the top.

## Layouts

That is the `dense` layout. Other layouts trade looks for larger lyrics. Name one by joining options with `+`:

| option | what it changes |
|---|---|
| `overlap` | chords sit lower, on the words themselves, outlined in white and drawn over them: no extra height for chords |
| `inline` | chords go in the lyric line, right before the syllable they land on; `‹G` marks a chord that sounds *before* its syllable |
| `packed` | rows break where the lyrics break (between couplets, not inside a rhyming pair or a repeated line) instead of as late as fits |
| `two-column` | two columns under a full-width heading |
| `shaded` | light bands for choruses and bridges, dark grey for lines already sung, chords coloured by function in the song's key (tonic darker, outside the key in orange) |

```bash
python -m songleaf sheet "wonderwall oasis" --layout inline
python -m songleaf sheet "wonderwall oasis" --layout overlap+packed+two-column+shaded
```

The comparison of the largest font size each layout fits, over the same 2,000 songs, is on [songleaf#4](https://github.com/thorwhalen/songleaf/issues/4).

## Command line

```bash
python -m songleaf search "wonderwall oasis"              # one query: list names, then titles, artists, lyrics
python -m songleaf search --artist oasis --lyrics "roads winding"   # narrower: every field given must match
python -m songleaf sheet "wonderwall oasis" --output wonderwall.pdf
python -m songleaf sheet "wonderwall oasis" --pick 2      # the second-best match
python -m songleaf songs                                  # songs already stored
python -m songleaf export kaggle_chords:1234 --output song.cho   # ChordPro (or --format text); --transpose 2
python -m songleaf viewed                                 # the songs opened, the latest first
python -m songleaf sheet kaggle_chords:1234               # a song by its key: no search
python -m songleaf sheet kaggle_chords:1234 --refresh     # fetch it again from its source
python -m songleaf sheet "wonderwall oasis" --transpose 2 # chords up a whole tone, spelled for the new key
python -m songleaf sheet "wonderwall oasis" --scale 0.8   # 80% of the size that fills the page
python -m songleaf songbook Campfire                       # a whole list as one PDF: contents, page numbers, bookmarks, in list order
python -m songleaf import chart.txt --title "Paper Boats"   # paste from any site: Ultimate Guitar markup, ChordPro, OpenSong, plain; "-" reads stdin
python -m songleaf sheet paste:ea2a9d845214                 # the key `import` printed
python -m songleaf song kaggle_chords:1234                # the song as data: text, chords, every key's chord table
python -m songleaf build-index                            # once: search from an index, not the whole corpus in memory
```

The `songleaf` console script is the same command.

## Python

```python
import songleaf

hits = songleaf.search("wonderwall oasis")
song = songleaf.get_song(hits[0].key)  # a Song: lyrics text + annotations
songleaf.render_dense_a4(song, "wonderwall.pdf")  # {'font_size': ..., 'pages': 1, ...}
songleaf.render_sheet(song, "wonderwall-inline.pdf", layout="inline+two-column")

songleaf.sheet("wonderwall oasis")  # search, store and render in one call
songleaf.tools.search(
    "wonderwall oasis"
)  # [{..., "match": "title", "highlight": {...}}, ...]
songleaf.sheet("wonderwall oasis", layout="overlap+shaded")
```

A layout is a `SheetLayout(chords=..., packing=..., columns=..., shading=..., style=DenseStyle(...))`. `chords` and `packing` also take your own strategy objects (a `ChordPlacement`, or a packer function with the signature of `songleaf.packing.pack_greedy`):

```python
from functools import partial
from songleaf import SheetLayout, render_sheet
from songleaf.packing import pack_structured

layout = SheetLayout(
    chords="inline", packing=partial(pack_structured, row_cost=5), columns=2
)
render_sheet(song, "sheet.pdf", layout=layout)
```

## The song model

A `Song` is lyrics text plus *standoff* annotations anchored on character offsets, so annotation layers never touch the text:

- `section`: spans its lines, `{"label": "Chorus"}`;
- `chord`: a point on the first character of the syllable the chord lands on, `{"symbol": "G/B", "timing": "at"}` (`"before"` marks a chord that sounds ahead of that syllable);
- `score`: a link from a stretch of lyrics to a score snippet, `{"source", "id", "url", "format"}`.

Metadata (title, artist, capo, key) and provenance (source, id, url, licence) travel with the song, and `Song.to_dict()` is its JSON form.

```python
from songleaf import Song, chord, section, parse_chords_over_lyrics

song = Song(
    "Paper boats drift", [section(0, 17, "Verse 1"), chord(0, "G"), chord(6, "D")]
)
song = parse_chords_over_lyrics(
    "G     D\nPaper boats drift"
)  # the same chords, from a chart
```

## Storage

Songs are kept in a `MutableMapping` (`songleaf.song_store()`): one JSON file per song under `~/.local/share/songleaf/songs/`. Rendered sheets go to `~/.local/share/songleaf/sheets/`. Set `SONGLEAF_DATA_DIR` to move both.

## Sources

`KaggleChordsSource` reads the [chords-and-lyrics dataset](https://www.kaggle.com/datasets/eitanbentora/chords-and-lyrics-dataset) through `sung`. It needs a local copy of the zip: put it at `~/.local/share/songleaf/datasets/chords-and-lyrics/chords-and-lyrics-dataset.zip` (under the data root, so `SONGLEAF_DATA_DIR` moves it too), point `SUNG_CHORDS_AND_LYRICS_ZIP` at it, or keep it where `haggle` downloads it (`$HAGGLE_ROOTDIR/zips/eitanbentora/chords-and-lyrics-dataset.zip`). Without a local copy, `sung` downloads it from Kaggle, which needs Kaggle credentials. The corpus was scraped from a chords site, so keep it to personal use.

Loading the corpus takes about five seconds on the first search in a process, and it stays in memory: about 1.1 GB, 1.6 GB once a lyrics search has run. Run `songleaf build-index` once to avoid that. It writes one SQLite file beside the zip (about 190 MB: titles, artists, compressed charts and a full-text index of the words), and from then on the default source reads it. A process then holds about 80 MB for search, a lyrics search takes milliseconds instead of seconds, and the songs and their keys are the same. Run it again after replacing the zip.

Other sources surveyed, plus the Ultimate Guitar / MuseScore decisions, are in [`misc/docs/sources.md`](misc/docs/sources.md).

`LocalFolderSource` indexes a folder of files you exported yourself from a paid chords/tab or score site (Ultimate Guitar, MuseScore, ...) — text charts are parsed like the Kaggle corpus, score/tab files (Guitar Pro, MusicXML, MSCZ, MIDI, PDF) are attached as a score link. Default folder: `~/.local/share/songleaf/imports/` (moved by `SONGLEAF_DATA_DIR`).

## Web app

`songleaf.web.mk_app()` is a FastAPI app over the same functions: search, `POST /import` (paste a song; the page has a "Paste a song" box), the stored songs, the layout options, and the sheet as JSON, PDF and a PNG per page (for phone browsers that show no PDF inline). It also serves its own page, built phone first: the song starts right under the title and one row of controls. That row holds the text size, the key (−1 / +1, and a key picker that lists every chord of the song beside what it becomes), **Aa** and **⋯**. **Aa** holds the reader's settings: paper (auto, light, sepia, dark, black), where the chords go (above the words, in the line, or off), one or two columns, shaded choruses, and under *Fine tuning* the colour and opacity of lyrics and chords, chord size, line spacing and font. **⋯** leads to **Print**, which shows the one-page PDF sheet. Its layout starts from the reader's settings and can be changed there in the same words (chords above, on the words or in the line; rows filled or broken at rhymes; one or two columns; shading). Opacity prints as the matching shade on white paper. The page is a two-line HTML shell that loads its script and style from the app, so it changes with the package.

```bash
pip install "songleaf[web]"
```

```python
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from songleaf.web import mk_app, page_shell

site = FastAPI()
site.mount("/api/songleaf", mk_app())
site.get("/songleaf/", response_class=HTMLResponse)(
    lambda: page_shell(api="/api/songleaf", base="/songleaf")
)
```

**One search field** looks in four places at once and shows what it found in sections, in this order: **In your lists** (lists whose name matches, each opening the library on that list, with its first songs), **Titles**, **Artists** and **Lyrics**. A song appears once, in the first section it fits: a song whose title and lyrics both match is a title match. The matched words are marked, a lyrics match shows the line that matched, and when no title matches a quiet line says so. Titles and artists match word by word: a query can be part of a title (`wonder`), or hold a whole title and artist (`wonderwall oasis`), and a word of four letters or more forgives a typo. In each section the songs whose title and artist hold the most words of the query come first, then the closest, then the most popular. The research and design behind it are on [songleaf#34](https://github.com/thorwhalen/songleaf/pull/34).

**Home** is the library on **Viewed**: the songs you opened, the latest first. It is kept on the server, per person when the host says who is asking (enlace passes the signed-in user; songleaf stores only a hash of the id), so it is the same on every device. The × beside a song takes it out of Viewed, with an Undo; it stays stored and in its lists, and the **Not in a list** chip and search still find it. A house icon at the top of every other view (search results, a list, a song) goes back home; when home is a known number of steps back it walks back to it, so it returns as you left it.

**Lists** work like Gmail labels: a song can be in several lists, and a list can hold other lists. Chips above the songs narrow them to a list (Viewed, each list, Not in a list); choosing a list that holds lists shows them in a second row. The chosen list is in the URL, so Back, reload and a shared link keep it. To file songs, tap the "in …" line under a song's title (or pick several songs with **Edit** and use **Lists…**): both open the same menu, where you tick lists or create one, then Apply. **Edit** also deletes songs (from storage, every list and Viewed). On a wide screen, **Arrange lists** shows the lists as a tree, where you can drag a song or a list onto a list (that is how lists go inside lists). Lists are shared by everyone using the app (one library). They are kept on the server as a [zodal-groups](https://github.com/i2mint/zodal-groups) snapshot with a revision (`songleaf.library`; the model and its file say "groups"). A save made from an out-of-date revision is refused, and the page replays its changes on top of the newer lists, so two people editing at once don't overwrite each other.

In Read view, the floating ▶ starts **auto-scroll**; while it runs, − and + set the speed. It is remembered per song, pauses when you touch the song, and keeps the screen awake while running.

The app has no login of its own: put it behind whatever gates the host. It takes the same `sources=` and `store=` as `sheet`. A rendered sheet is kept in memory (`sheet_cache_size=`, default 32), so the page's requests for one sheet render it once.

## Extending

Three keyword arguments are the extension points, on `sheet` (and `search` for sources):

- `sources=`: objects with a `name`, `search(query, *, title, artist, lyrics, limit)` returning `Hit`s, and `get(song_id)` returning a `Song`;
- `store=`: any `MutableMapping[str, Song]` (a `dict` works);
- `renderer=`: any `(song, output) -> dict` function (`songleaf.make_renderer("inline+shaded", page_size="LETTER")` makes one from a layout). It replaces `layout=`.

```python
songleaf.sheet("paper boats", sources=[my_source], store={}, renderer=my_renderer)
```
