Choir Warmup Database with LilyPond
Build a CLI app that stores choir warmups and renders notation PNGs.
Introduction
30 Second Summary
Every choir conductor has a mental library of warmup exercises. Some train breath support, others help singers match pitch. But keeping track of which exercise works for what purpose, and having notation ready to share, usually means digging through scattered folders of handwritten notes and PDFs.
In this project, you will build a command-line tool called "Warmup Manager" that stores choir warmup exercises in a local database and renders their musical notation as PNG images on demand.
What You'll Build
A Python CLI application that uses SQLAlchemy to manage exercise data in SQLite and calls LilyPond to generate publication-quality notation images from stored source code.
By the end of this project, you'll have:
- A SQLAlchemy ORM model for exercises with metadata like purpose, vocal range, and LilyPond source.
- A Typer CLI with commands to add exercises, list them by purpose, and render their notation as PNG images.
- A subprocess rendering pipeline that calls LilyPond to produce cropped PNG notation images from stored source.
- Secret Mission: extend the system with SATB multi-voice support for four-part choral notation.
Are there any prerequisites?
You should have basic Python knowledge (functions, classes, f-strings) and familiarity with SQL concepts like tables, columns, and queries. No prior experience with ORM frameworks or LilyPond is needed.
Set Up Your Project Environment
Before building the Warmup Manager, you need a clean workspace with the right tools installed. A dedicated project folder and Python virtual environment keep your dependencies isolated from other projects on your machine.
In this step, you'll create the project directory, configure a virtual environment, and install the two Python packages that power the application.
In this step, get ready to:
- Create a project directory with a Python virtual environment.
- Install SQLAlchemy and Typer via pip.
- Confirm LilyPond is accessible from the command line.
Create a project directory and virtual environment
A virtual environment is an isolated Python installation inside your project folder. Packages you install inside it stay contained, preventing conflicts with other projects.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir warmup-manager
cd warmup-manager
- Create and activate your virtual environment. The commands differ by operating system.
macOS
- Run these commands to create and activate the virtual environment:
python3 -m venv .venv
source .venv/bin/activate
Windows
- Run these commands to create and activate the virtual environment:
python -m venv .venv
.venv\Scripts\activate
How do I know the virtual environment is active?
After activation, your terminal prompt changes to show (.venv) at the beginning of the line. This confirms all pip installs and python commands now target the isolated environment.
- Verify your Python version by running:
python --version
✔️ I see Python 3.13
Your virtual environment is using the correct Python version. Move on to the next substep.
ⓧ I see a different version or an error
If you see a version lower than 3.13, your venv may have been created with a different Python installation.
- Deactivate the current environment by running deactivate.
- Delete the .venv folder inside warmup-manager.
- Recreate the venv using the full path to your Python 3.13 executable (e.g. /usr/local/bin/python3.13 -m venv .venv on macOS or py -3.13 -m venv .venv on Windows).
- Activate again and re-run python --version.
Install your project dependencies
The Warmup Manager relies on two packages. SQLAlchemy handles all database operations through an ORM layer. Typer turns Python functions into a polished command-line interface.
- Install both packages by running:
pip install SQLAlchemy==2.0.51 typer==0.26.7
What does this command do?
This installs pinned versions of both libraries into your virtual environment. Pinning versions (==2.0.51, ==0.26.7) ensures your code behaves consistently regardless of when you run the install.
Typer automatically installs rich and shellingham as dependencies. You'll use rich later for beautifully formatted table output in the terminal.
- Verify the installation by running:
pip list
You should see both SQLAlchemy and typer in the output list along with their dependencies.
Seeing a permission error or install failure?
Make sure your virtual environment is active (you should see (.venv) in your prompt). If you run pip install without the venv active, it tries to install system-wide and may require admin privileges.
If you see a network timeout, check your internet connection and try running the command again.
Help me troubleshoot my pip install failure for SQLAlchemy and Typer in a Python 3.13 virtual environment.
Verify LilyPond is accessible
The rendering pipeline you'll build later calls LilyPond as an external program via Python's subprocess module. For that to work, your system needs to find the lilypond command from any terminal session.
- Check that LilyPond is in your system PATH by running:
lilypond --version
✔️ I see version 2.24.4
LilyPond is correctly installed and accessible from your terminal. Your rendering pipeline will be able to call it directly.
ⓧ Command not found
LilyPond is installed but your terminal cannot find it. This means the LilyPond executable is not in your system's PATH.
- Find where LilyPond is installed on your machine (check your Applications folder on macOS, or Program Files on Windows).
- Note the full path to the lilypond executable (e.g. C:\Program Files\LilyPond\usr\bin\lilypond.exe on Windows).
- Add that directory to your system PATH, then restart your terminal and try lilypond --version again.
Help me add LilyPond to my system PATH so I can run it from any terminal.
- Now open your project in PyCharm Educational. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type PyCharm and press Enter to open it.
- In PyCharm, click File in the top menu bar.
- Click Open.
- Navigate to your Desktop and select the warmup-manager folder, then click OK (Windows) or Open (macOS).
Why open the project in PyCharm?
PyCharm Educational provides an integrated terminal, code completion for SQLAlchemy models, and inline error highlighting. Its built-in terminal automatically detects and activates your .venv when you open the project.
You should see the warmup-manager folder open in PyCharm's Project sidebar with only the .venv folder inside it.
Use PyCharm's terminal from here on
For all remaining terminal commands in this project, use the Terminal panel at the bottom of PyCharm (click the Terminal tab or press Alt+F12). It automatically activates your virtual environment.
Your environment is ready to go. Next up, you'll design the database schema that stores warmup exercises with their metadata and notation source.
Design the Database Schema
Your environment is ready, and now it is time to design the data layer for your warmup exercises.
Every exercise in your collection has structured metadata: a name, its purpose, the vocal range it covers, and the LilyPond source that defines its notation. To store this data reliably, you will define a SQLAlchemy ORM model that maps a Python class directly to a SQLite database table.
In this step, get ready to:
- Define an ORM model for warmup exercises with columns for metadata, vocal range, and notation source.
- Create a database configuration module with engine and session management.
- Initialise the SQLite database and verify the table structure.
Create the ORM model
An ORM model is a Python class that maps to a database table. Each attribute on the class becomes a column in the table. SQLAlchemy's declarative mapping style lets you define both the table structure and Python types in one place.
- In the Project tool window on the left side of PyCharm, right-click your project folder.
- Select New, then Python File.
- Type models and press Enter to create models.py.
- Add the imports and Base class by pasting this code into the file:
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Text, DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
# Base class that all ORM models inherit from
class Base(DeclarativeBase):
pass
What does this code do?
The imports pull in SQLAlchemy's column types (String, Text, DateTime) and the mapping tools (Mapped, mapped_column) that let you declare columns using Python type hints.
DeclarativeBase is the foundation for all your models. Any class that inherits from Base becomes a mapped table in the database.
- Below the Base class, add the Exercise model by pasting this code:
class Exercise(Base):
__tablename__ = "exercises"
# Primary key auto-increments
id: Mapped[int] = mapped_column(primary_key=True)
# Exercise display name
name: Mapped[str] = mapped_column(String(100))
# Optional longer description
description: Mapped[Optional[str]] = mapped_column(Text, default=None)
# Category like "breath support" or "intonation"
purpose: Mapped[str] = mapped_column(String(50))
# Vocal range boundaries in LilyPond notation
lowest_note: Mapped[str] = mapped_column(String(10))
highest_note: Mapped[str] = mapped_column(String(10))
# The LilyPond source code for rendering notation
lilypond_source: Mapped[str] = mapped_column(Text)
# Timestamp set automatically by the database
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
def __repr__(self) -> str:
return f"Exercise(id={self.id!r}, name={self.name!r}, purpose={self.purpose!r})"
What does this code do?
The Exercise class maps to a table called exercises in the database. Each Mapped[...] annotation declares a column with its Python type and database constraints.
The lowest_note and highest_note columns store notes in LilyPond format (like c' for middle C or g'' for the G an octave above the staff). The server_default=func.now() tells SQLite to set the timestamp automatically when a row is inserted.
- Save models.py with Ctrl+S (Windows) or Cmd+S (macOS).
Seeing red underlines in PyCharm?
If PyCharm shows import errors, it may not have detected your virtual environment. Go to File > Settings > Project: Interpreter and select the .venv interpreter you created in Step 1.
help me fix PyCharm import errors for SQLAlchemy in my virtual environment
Create the database configuration
The model defines the shape of your data, but you also need code that connects to the database and creates tables. A separate database.py module keeps this configuration in one place.
- Right-click your project folder in the Project tool window again.
- Select New > Python File, type database, and press Enter.
- Paste the following code into database.py:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from models import Base
# Connection string pointing to a local SQLite file
DATABASE_URL = "sqlite:///warmups.db"
# Engine manages the connection pool
engine = create_engine(DATABASE_URL, echo=False)
def init_db():
"""Create all tables defined in models.py."""
Base.metadata.create_all(engine)
def get_session() -> Session:
"""Return a new database session for queries."""
return Session(engine)
if __name__ == "__main__":
init_db()
print("Database initialised successfully.")
What does this code do?
create_engine("sqlite:///warmups.db") tells SQLAlchemy to use a local file called warmups.db as the database. SQLite needs no server installation because the database is just a file in your project folder.
init_db() reads every model that inherits from Base and creates the matching table if it does not already exist. get_session() returns a session object you will use later to insert and query exercises.
The if __name__ == "__main__" block means you can run this file directly to initialise the database without importing it from another script.
- Save database.py.
Seeing an import error for models?
The line from models import Base requires models.py to be in the same directory as database.py. Make sure both files are in the project root, not inside a subfolder.
help me fix a ModuleNotFoundError for models in my Python project
Test the schema by initialising the database
Running database.py directly will create the warmups.db file and set up the exercises table inside it. This confirms that your model definition and engine configuration work together correctly.
- Open the terminal inside PyCharm by clicking Terminal at the bottom of the window (or press Alt+F12).
- Run the database initialisation script:
python database.py
- You should see the message Database initialised successfully. printed in the terminal.
- Confirm the database file was created by running:
ls warmups.db
- You should see warmups.db listed in the output.
Not seeing warmups.db?
Make sure your terminal is in the same directory as database.py and models.py. Run ls to check that both .py files are visible. If they are not, use cd to navigate to your project folder.
help me figure out why python database.py did not create warmups.db
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the complete models.py:
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Text, DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Exercise(Base):
__tablename__ = "exercises"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
description: Mapped[Optional[str]] = mapped_column(Text, default=None)
purpose: Mapped[str] = mapped_column(String(50))
lowest_note: Mapped[str] = mapped_column(String(10))
highest_note: Mapped[str] = mapped_column(String(10))
lilypond_source: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
def __repr__(self) -> str:
return f"Exercise(id={self.id!r}, name={self.name!r}, purpose={self.purpose!r})"
Here is the complete database.py:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from models import Base
DATABASE_URL = "sqlite:///warmups.db"
engine = create_engine(DATABASE_URL, echo=False)
def init_db():
Base.metadata.create_all(engine)
def get_session() -> Session:
return Session(engine)
if __name__ == "__main__":
init_db()
print("Database initialised successfully.")
Your database schema is in place. Next up, you will build the rendering pipeline that converts LilyPond source code into PNG notation images.
Build the LilyPond Rendering Pipeline
Your database can store exercise metadata and LilyPond source code, but the source is just text. To turn that text into visual notation images, you need a rendering pipeline that bridges Python and LilyPond.
In this step, you will build a Python module that writes temporary .ly files, calls LilyPond via subprocess, and saves cropped PNG output to a local folder. The result is a reusable function your CLI commands will call later.
In this step, get ready to:
- Build a LilyPond template with paper settings that produce clean, headerless notation snippets.
- Write a rendering function that generates temporary files and calls LilyPond to produce PNGs.
- Test the pipeline by rendering a sample scale and verifying the PNG output.
Create the renderer module and template
LilyPond needs specific \paper and \header settings to output a clean snippet without page margins, footers, or titles. You will define these settings once in a template string that your rendering function reuses for every exercise.
- Create a new file called renderer.py in your project folder (the same folder as models.py).
- Add the imports, output directory constant, and LilyPond template by pasting this code:
import subprocess
import tempfile
from pathlib import Path
# Directory where rendered PNGs will be saved
OUTPUT_DIR = Path("output")
# LilyPond source template with clean snippet settings
LILYPOND_TEMPLATE = r"""\ version "2.24.4"
\paper {{
indent = 0\mm
oddFooterMarkup = ##f
oddHeaderMarkup = ##f
bookTitleMarkup = ##f
scoreTitleMarkup = ##f
}}
\header {{
tagline = ##f
}}
{{
{music}
}}
"""
What do these template settings do?
- indent = 0\mm removes the default indentation on the first line of music.
- oddFooterMarkup = ##f and oddHeaderMarkup = ##f disable the page footer and header.
- bookTitleMarkup = ##f and scoreTitleMarkup = ##f prevent title blocks from rendering.
- tagline = ##f in the \header block removes the default "Music engraving by LilyPond" text.
- The double curly braces {{ and }} are Python's way of writing literal braces inside a format string. The single {music} is the placeholder where exercise notation gets inserted.
Write the rendering function
The rendering function takes an exercise name and its LilyPond source, writes a temporary .ly file, invokes LilyPond to generate a PNG, then copies the cropped result into the output/ directory.
- Add the render_exercise function below the template in renderer.py:
def render_exercise(name: str, lilypond_source: str) -> Path:
# Create the output directory if it doesn't exist
OUTPUT_DIR.mkdir(exist_ok=True)
# Insert the music source into the template
ly_content = LILYPOND_TEMPLATE.format(music=lilypond_source)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
ly_file = tmp_path / "exercise.ly"
ly_file.write_text(ly_content, encoding="utf-8")
# Call LilyPond to render the .ly file as a cropped PNG
result = subprocess.run(
["lilypond", "-fpng", "-dcrop", "-o", str(tmp_path / "exercise"), str(ly_file)],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"LilyPond rendering failed:\n{result.stderr}"
)
# LilyPond with -dcrop produces exercise.cropped.png
cropped_file = tmp_path / "exercise.cropped.png"
if not cropped_file.exists():
png_file = tmp_path / "exercise.png"
if not png_file.exists():
raise FileNotFoundError(
"LilyPond did not produce a PNG file."
)
cropped_file = png_file
# Copy the rendered PNG to the output directory
safe_name = name.lower().replace(" ", "_")
output_file = OUTPUT_DIR / f"{safe_name}.png"
output_file.write_bytes(cropped_file.read_bytes())
return output_file
What does this function do?
- It creates a temporary directory, writes the filled template as exercise.ly, and calls lilypond -fpng -dcrop on it.
- The -dcrop flag tells LilyPond to generate an extra file named exercise.cropped.png containing only the music content with no surrounding page margins.
- subprocess.run() captures LilyPond's output so your Python code can check whether it succeeded or failed.
- The function converts the exercise name to a safe filename (lowercase, underscores) and copies the PNG into output/.
Why use a temporary directory?
LilyPond generates several intermediate files (.ps, .log, uncropped .png) alongside the output you actually want. By working inside a tempfile.TemporaryDirectory(), all those intermediate files are cleaned up automatically when the with block exits.
Test the rendering pipeline
A quick test script at the bottom of the file lets you run renderer.py directly to confirm LilyPond produces a PNG.
- Add this test block at the bottom of renderer.py, below the render_exercise function:
if __name__ == "__main__":
# Render a simple descending scale as a test
test_source = r"\\relative c' { c d e f g f e d c }"
output = render_exercise("test_scale", test_source)
print(f"Rendered to: {output}")
What does this test do?
The if __name__ == "__main__" guard means this code only runs when you execute renderer.py directly. It passes a short LilyPond snippet (a C-major scale fragment) to render_exercise and prints the output path.
- Save renderer.py.
- Run the test by executing this command in your terminal:
python renderer.py
- You should see output like Rendered to: output/test_scale.png printed in your terminal.
- Confirm the file exists by running:
ls output
- You should see test_scale.png listed in the output.
Not seeing the PNG?
- If you see a RuntimeError mentioning LilyPond rendering failed, check that lilypond is accessible in your PATH by running lilypond --version in your terminal.
- If you get a FileNotFoundError about no PNG file, make sure you are running from the project directory where renderer.py lives.
- On Windows, if LilyPond is not in your PATH, you may need to replace "lilypond" in the subprocess call with the full path to your LilyPond executable.
Help me debug my LilyPond rendering pipeline
✔️ Awesome, I've got everything!
Great. Double check you have saved renderer.py and that test_scale.png exists in your output/ folder.
ⓧ I'd like to double check the full code
import subprocess
import tempfile
from pathlib import Path
OUTPUT_DIR = Path("output")
LILYPOND_TEMPLATE = r"""\ version "2.24.4"
\paper {{
indent = 0\mm
oddFooterMarkup = ##f
oddHeaderMarkup = ##f
bookTitleMarkup = ##f
scoreTitleMarkup = ##f
}}
\header {{
tagline = ##f
}}
{{
{music}
}}
"""
def render_exercise(name: str, lilypond_source: str) -> Path:
OUTPUT_DIR.mkdir(exist_ok=True)
ly_content = LILYPOND_TEMPLATE.format(music=lilypond_source)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
ly_file = tmp_path / "exercise.ly"
ly_file.write_text(ly_content, encoding="utf-8")
result = subprocess.run(
["lilypond", "-fpng", "-dcrop", "-o", str(tmp_path / "exercise"), str(ly_file)],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"LilyPond rendering failed:\n{result.stderr}"
)
cropped_file = tmp_path / "exercise.cropped.png"
if not cropped_file.exists():
png_file = tmp_path / "exercise.png"
if not png_file.exists():
raise FileNotFoundError(
"LilyPond did not produce a PNG file."
)
cropped_file = png_file
safe_name = name.lower().replace(" ", "_")
output_file = OUTPUT_DIR / f"{safe_name}.png"
output_file.write_bytes(cropped_file.read_bytes())
return output_file
if __name__ == "__main__":
test_source = r"\\relative c' { c d e f g f e d c }"
output = render_exercise("test_scale", test_source)
print(f"Rendered to: {output}")
Your rendering pipeline is working. Next up, you will build a CLI application that ties together the database and the renderer into a single tool with commands for adding, listing, and rendering exercises.
Create the CLI Application
Your database schema is ready, and your rendering pipeline can turn LilyPond source into PNG images. The missing piece is a way for you to actually interact with the system without writing Python scripts every time.
In this step, you will build a Typer-based CLI application with three commands: add, list, and render. Each command connects the ORM models and renderer you already built into a single entry point.
In this step, get ready to:
- Create a Typer app with imports and configuration.
- Implement the add and list commands with database integration.
- Implement the render command to generate PNGs on demand.
Set up the Typer app scaffolding
Typer uses a simple pattern: you create an app instance, then decorate Python functions with @app.command() to turn them into CLI subcommands.
- Create a new file called main.py in your project folder.
- Add the imports and app setup by pasting this code:
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from sqlalchemy import select
from database import get_session, init_db
from models import Exercise
from renderer import render_exercise
# Create the CLI app instance
app = typer.Typer(no_args_is_help=True)
# Rich console for styled terminal output
console = Console()
What does this code do?
The first block imports Typer for the CLI framework, Rich for styled terminal output (tables and coloured text), and SQLAlchemy's select for database queries.
The second block imports your own modules: get_session and init_db from database.py, the Exercise model, and the render_exercise function.
Setting no_args_is_help=True means running python main.py with no arguments will automatically display the help text listing available commands.
- Save main.py.
You won't see a change yet. The next chunks add the commands that bring this file to life.
Implement the add command
The add command prompts the user for exercise details, saves the exercise to the database, and immediately renders the notation PNG.
- In main.py, add the following code below the console = Console() line:
@app.command()
def add(
name: str = typer.Option(..., prompt=True, help="Exercise name"),
purpose: str = typer.Option(..., prompt=True, help="Purpose (e.g. breath support, intonation)"),
lowest_note: str = typer.Option(..., prompt=True, help="Lowest note in LilyPond notation (e.g. c')"),
highest_note: str = typer.Option(..., prompt=True, help="Highest note in LilyPond notation (e.g. g'')"),
lilypond_source: str = typer.Option(..., prompt=True, help="LilyPond music source code"),
description: Optional[str] = typer.Option(None, help="Optional description"),
):
"""Add a new warmup exercise to the database."""
init_db()
# Create the Exercise ORM instance from user input
exercise = Exercise(
name=name,
description=description,
purpose=purpose,
lowest_note=lowest_note,
highest_note=highest_note,
lilypond_source=lilypond_source,
)
# Persist to the database
with get_session() as session:
session.add(exercise)
session.commit()
session.refresh(exercise)
console.print(f"[green]Added exercise:[/green] {exercise.name} (id={exercise.id})")
# Auto-render the notation PNG
output_path = render_exercise(exercise.name, exercise.lilypond_source)
console.print(f"[blue]Rendered notation:[/blue] {output_path}")
What does this code do?
- Each parameter uses typer.Option(..., prompt=True) which means Typer will interactively prompt the user if they don't provide the value as a flag.
- The description parameter defaults to None and has no prompt, so it stays optional.
- After committing to the database, session.refresh(exercise) reloads the object so the auto-generated id is available.
- The function immediately calls render_exercise() so every new exercise gets a notation PNG on creation.
- Save main.py.
You won't see a change yet. The next chunk adds the list command, and then you will test both together.
Implement the list command
The list command queries exercises from the database and displays them in a formatted table using Rich. Rich is bundled with Typer, so no extra install is needed.
- In main.py, add the following code below the add function:
@app.command("list")
def list_exercises(
purpose: Optional[str] = typer.Option(None, help="Filter by purpose"),
):
"""List all warmup exercises, optionally filtered by purpose."""
init_db()
with get_session() as session:
# Build the query, optionally filtering by purpose
stmt = select(Exercise)
if purpose:
stmt = stmt.where(Exercise.purpose == purpose)
stmt = stmt.order_by(Exercise.name)
exercises = session.scalars(stmt).all()
if not exercises:
console.print("[yellow]No exercises found.[/yellow]")
return
# Build a Rich table for formatted output
table = Table(title="Warmup Exercises")
table.add_column("ID", style="dim")
table.add_column("Name", style="bold")
table.add_column("Purpose", style="cyan")
table.add_column("Range")
table.add_column("Description")
for ex in exercises:
table.add_row(
str(ex.id),
ex.name,
ex.purpose,
f"{ex.lowest_note} - {ex.highest_note}",
ex.description or "",
)
console.print(table)
What does this code do?
- The decorator @app.command("list") names the command explicitly because list is a Python built-in. The function itself is named list_exercises to avoid shadowing.
- The optional --purpose flag lets you filter exercises. Without it, all exercises are shown.
- Rich's Table class formats the output with column headers, alignment, and colour styling.
- Save main.py.
- Test the add command by running:
python main.py add
What should I see?
Typer will prompt you for each field one at a time. Enter these values to test:
- Name: Test Scale
- Purpose: breath support
- Lowest note: c'
- Highest note: g'
- LilyPond source: \relative c' { c d e f g }
You should see a green confirmation message and a blue line reporting the rendered PNG path.
- Now test the list command by running:
python main.py list
You should see a styled table in your terminal with the exercise you just added, showing its ID, Name, Purpose, Range, and Description columns.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is activated. You should see (.venv) at the start of your terminal prompt.
- Confirm that models.py, database.py, and renderer.py are all in the same folder as main.py.
Help me debug a ModuleNotFoundError when running main.py
Implement the render command
The render command fetches a specific exercise from the database and passes its LilyPond source to your rendering pipeline.
- In main.py, add the following code below the list_exercises function:
@app.command()
def render(
name: Optional[str] = typer.Option(None, help="Exercise name to render"),
exercise_id: Optional[int] = typer.Option(None, "--id", help="Exercise ID to render"),
):
"""Render the notation PNG for a specific exercise."""
if not name and exercise_id is None:
console.print("[red]Provide either --name or --id[/red]")
raise typer.Exit(1)
init_db()
with get_session() as session:
# Build query based on which identifier was provided
stmt = select(Exercise)
if name:
stmt = stmt.where(Exercise.name == name)
else:
stmt = stmt.where(Exercise.id == exercise_id)
exercise = session.scalars(stmt).first()
if not exercise:
console.print("[red]Exercise not found.[/red]")
raise typer.Exit(1)
output_path = render_exercise(exercise.name, exercise.lilypond_source)
console.print(f"[green]Rendered:[/green] {output_path}")
if __name__ == "__main__":
app()
What does this code do?
- The command accepts either --name or --id to identify the exercise. If neither is given, it prints an error and exits.
- It queries the database for the matching exercise, then calls render_exercise() to generate the PNG.
- The if __name__ == "__main__" block at the bottom ensures the app runs when you execute python main.py directly.
- Save main.py.
- Test the render command using the exercise you added earlier by running:
python main.py render --name "Test Scale"
You should see a green message reporting the output path of the generated PNG file.
- Confirm the PNG was created by running:
ls output/
You should see test_scale.png listed in the output.
Seeing "Exercise not found"?
- Make sure the --name value matches exactly what you typed when you ran the add command, including capitalisation.
- Try using --id 1 instead if you are unsure of the name spelling.
- Run python main.py list to see the exact names and IDs in your database.
Help me debug why my render command can't find the exercise
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.py for comparison:
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from sqlalchemy import select
from database import get_session, init_db
from models import Exercise
from renderer import render_exercise
app = typer.Typer(no_args_is_help=True)
console = Console()
@app.command()
def add(
name: str = typer.Option(..., prompt=True, help="Exercise name"),
purpose: str = typer.Option(..., prompt=True, help="Purpose (e.g. breath support, intonation)"),
lowest_note: str = typer.Option(..., prompt=True, help="Lowest note in LilyPond notation (e.g. c')"),
highest_note: str = typer.Option(..., prompt=True, help="Highest note in LilyPond notation (e.g. g'')"),
lilypond_source: str = typer.Option(..., prompt=True, help="LilyPond music source code"),
description: Optional[str] = typer.Option(None, help="Optional description"),
):
"""Add a new warmup exercise to the database."""
init_db()
exercise = Exercise(
name=name,
description=description,
purpose=purpose,
lowest_note=lowest_note,
highest_note=highest_note,
lilypond_source=lilypond_source,
)
with get_session() as session:
session.add(exercise)
session.commit()
session.refresh(exercise)
console.print(f"[green]Added exercise:[/green] {exercise.name} (id={exercise.id})")
output_path = render_exercise(exercise.name, exercise.lilypond_source)
console.print(f"[blue]Rendered notation:[/blue] {output_path}")
@app.command("list")
def list_exercises(
purpose: Optional[str] = typer.Option(None, help="Filter by purpose"),
):
"""List all warmup exercises, optionally filtered by purpose."""
init_db()
with get_session() as session:
stmt = select(Exercise)
if purpose:
stmt = stmt.where(Exercise.purpose == purpose)
stmt = stmt.order_by(Exercise.name)
exercises = session.scalars(stmt).all()
if not exercises:
console.print("[yellow]No exercises found.[/yellow]")
return
table = Table(title="Warmup Exercises")
table.add_column("ID", style="dim")
table.add_column("Name", style="bold")
table.add_column("Purpose", style="cyan")
table.add_column("Range")
table.add_column("Description")
for ex in exercises:
table.add_row(
str(ex.id),
ex.name,
ex.purpose,
f"{ex.lowest_note} - {ex.highest_note}",
ex.description or "",
)
console.print(table)
@app.command()
def render(
name: Optional[str] = typer.Option(None, help="Exercise name to render"),
exercise_id: Optional[int] = typer.Option(None, "--id", help="Exercise ID to render"),
):
"""Render the notation PNG for a specific exercise."""
if not name and exercise_id is None:
console.print("[red]Provide either --name or --id[/red]")
raise typer.Exit(1)
init_db()
with get_session() as session:
stmt = select(Exercise)
if name:
stmt = stmt.where(Exercise.name == name)
else:
stmt = stmt.where(Exercise.id == exercise_id)
exercise = session.scalars(stmt).first()
if not exercise:
console.print("[red]Exercise not found.[/red]")
raise typer.Exit(1)
output_path = render_exercise(exercise.name, exercise.lilypond_source)
console.print(f"[green]Rendered:[/green] {output_path}")
if __name__ == "__main__":
app()
Why Typer instead of argparse?
Python's built-in argparse module works fine for simple scripts, but Typer uses Python type hints to generate the CLI interface automatically. You get interactive prompts, help text, and coloured output with almost no boilerplate.
Typer is built on top of Click, the most popular Python CLI library, but removes the need for manual decorator configuration. Defining a function's parameters is all you need to define the CLI interface.
Your CLI is fully functional. You can add exercises, browse them in a styled table, and render their notation on demand. Next up, you will wire everything together with auto-rendering and seed data to populate your collection.
Wire It All Together
Your CLI can add exercises, list them, and render individual notation PNGs.
In this step, you will add batch rendering and seed the database with real warmup exercises. This transforms your tool from a single-exercise demo into a populated conductor's reference.
In this step, get ready to:
- Confirm auto-rendering works when adding exercises.
- Add a batch render-all command with a --force option.
- Create a seed script with four warmup exercises and generate all their PNGs.
Confirm auto-rendering on add
The add command you built in the previous step already calls render_exercise() after saving to the database. Every time you add an exercise, a PNG is automatically generated and its path is printed.
- Verify this by checking your output/ folder. If you added a test exercise in the previous step, its PNG should already exist there.
Why auto-render on add?
Generating the PNG immediately gives the user instant feedback that their LilyPond source is valid. If the notation has a syntax error, LilyPond reports it right away instead of failing silently until a later render.
With single-exercise rendering confirmed, the next piece is a batch command that regenerates PNGs for the entire collection.
Add a render-all command
A batch rendering command lets you regenerate all notation images in one go. This is useful if you change the LilyPond template settings or want to re-render after editing exercises.
- In main.py, find the last line of the render function:
console.print(f"[green]Rendered:[/green] {output_path}")
- Add the following render-all command below it, before the if __name__ == "__main__": block:
@app.command("render-all")
def render_all(
force: bool = typer.Option(False, "--force", help="Overwrite existing files"),
):
"""Render notation PNGs for all exercises."""
init_db()
with get_session() as session:
exercises = session.scalars(select(Exercise)).all()
if not exercises:
console.print("[yellow]No exercises to render.[/yellow]")
return
from pathlib import Path
for ex in exercises:
safe_name = ex.name.lower().replace(" ", "_")
output_file = Path("output") / f"{safe_name}.png"
if output_file.exists() and not force:
console.print(f"[dim]Skipping {ex.name} (already exists)[/dim]")
continue
output_path = render_exercise(ex.name, ex.lilypond_source)
console.print(f"[green]Rendered:[/green] {ex.name} -> {output_path}")
What does this code do?
- The --force option defaults to False. Without it, existing PNGs are skipped to save time.
- For each exercise, the function builds the expected filename and checks if it already exists on disk.
- If the file exists and --force is not set, the exercise is skipped with a dimmed message.
- Otherwise, it calls render_exercise() to generate the PNG fresh.
- Save main.py.
- Test the new command by running:
python main.py render-all
You should see either rendered confirmations or "Skipping" messages for exercises that already have PNGs.
Seeing 'No exercises to render'?
This means your database has no exercises yet. That is fine. The seed script in the next substep will populate it.
If you previously added a test exercise in Step 4 but see this message, make sure you are running the command from the same directory that contains warmups.db.
help me debug why render-all shows no exercises
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py before moving on.
ⓧ I'd like to double check the full code
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from sqlalchemy import select
from database import get_session, init_db
from models import Exercise
from renderer import render_exercise
app = typer.Typer(no_args_is_help=True)
console = Console()
@app.command()
def add(
name: str = typer.Option(..., prompt=True, help="Exercise name"),
purpose: str = typer.Option(..., prompt=True, help="Purpose (e.g. breath support, intonation)"),
lowest_note: str = typer.Option(..., prompt=True, help="Lowest note in LilyPond notation (e.g. c')"),
highest_note: str = typer.Option(..., prompt=True, help="Highest note in LilyPond notation (e.g. g'')"),
lilypond_source: str = typer.Option(..., prompt=True, help="LilyPond music source code"),
description: Optional[str] = typer.Option(None, help="Optional description"),
):
"""Add a new warmup exercise to the database."""
init_db()
exercise = Exercise(
name=name,
description=description,
purpose=purpose,
lowest_note=lowest_note,
highest_note=highest_note,
lilypond_source=lilypond_source,
)
with get_session() as session:
session.add(exercise)
session.commit()
session.refresh(exercise)
console.print(f"[green]Added exercise:[/green] {exercise.name} (id={exercise.id})")
output_path = render_exercise(exercise.name, exercise.lilypond_source)
console.print(f"[blue]Rendered notation:[/blue] {output_path}")
@app.command("list")
def list_exercises(
purpose: Optional[str] = typer.Option(None, help="Filter by purpose"),
):
"""List all warmup exercises, optionally filtered by purpose."""
init_db()
with get_session() as session:
stmt = select(Exercise)
if purpose:
stmt = stmt.where(Exercise.purpose == purpose)
stmt = stmt.order_by(Exercise.name)
exercises = session.scalars(stmt).all()
if not exercises:
console.print("[yellow]No exercises found.[/yellow]")
return
table = Table(title="Warmup Exercises")
table.add_column("ID", style="dim")
table.add_column("Name", style="bold")
table.add_column("Purpose", style="cyan")
table.add_column("Range")
table.add_column("Description")
for ex in exercises:
table.add_row(
str(ex.id),
ex.name,
ex.purpose,
f"{ex.lowest_note} - {ex.highest_note}",
ex.description or "",
)
console.print(table)
@app.command()
def render(
name: Optional[str] = typer.Option(None, help="Exercise name to render"),
exercise_id: Optional[int] = typer.Option(None, "--id", help="Exercise ID to render"),
):
"""Render the notation PNG for a specific exercise."""
if not name and exercise_id is None:
console.print("[red]Provide either --name or --id[/red]")
raise typer.Exit(1)
init_db()
with get_session() as session:
stmt = select(Exercise)
if name:
stmt = stmt.where(Exercise.name == name)
else:
stmt = stmt.where(Exercise.id == exercise_id)
exercise = session.scalars(stmt).first()
if not exercise:
console.print("[red]Exercise not found.[/red]")
raise typer.Exit(1)
output_path = render_exercise(exercise.name, exercise.lilypond_source)
console.print(f"[green]Rendered:[/green] {output_path}")
@app.command("render-all")
def render_all(
force: bool = typer.Option(False, "--force", help="Overwrite existing files"),
):
"""Render notation PNGs for all exercises."""
init_db()
with get_session() as session:
exercises = session.scalars(select(Exercise)).all()
if not exercises:
console.print("[yellow]No exercises to render.[/yellow]")
return
from pathlib import Path
for ex in exercises:
safe_name = ex.name.lower().replace(" ", "_")
output_file = Path("output") / f"{safe_name}.png"
if output_file.exists() and not force:
console.print(f"[dim]Skipping {ex.name} (already exists)[/dim]")
continue
output_path = render_exercise(ex.name, ex.lilypond_source)
console.print(f"[green]Rendered:[/green] {ex.name} -> {output_path}")
if __name__ == "__main__":
app()
Create the seed script
A seed script pre-populates your database with real warmup exercises so you can test the full system without manually entering each one.
- Create a new file called seed.py in your project folder and add the following code:
from database import get_session, init_db
from models import Exercise
from renderer import render_exercise
# Four common choir warmup exercises with LilyPond notation
SEED_EXERCISES = [
{
"name": "Descending Five-Note Scale",
"description": "Classic descending scale for warming up the voice gently.",
"purpose": "breath support",
"lowest_note": "c'",
"highest_note": "g'",
"lilypond_source": r"\relative c'' { g f e d c }",
},
{
"name": "Ascending Arpeggio",
"description": "Major triad arpeggio ascending and descending for range extension.",
"purpose": "range extension",
"lowest_note": "c'",
"highest_note": "g'",
"lilypond_source": r"\relative c' { c e g e c }",
},
{
"name": "Sustained Unison",
"description": "Long sustained note for tuning and breath control.",
"purpose": "intonation",
"lowest_note": "f'",
"highest_note": "f'",
"lilypond_source": r"\relative c'' { f1~ f }",
},
{
"name": "Stepwise Legato",
"description": "Ascending and descending legato line for vowel unification.",
"purpose": "vowel unification",
"lowest_note": "c'",
"highest_note": "a'",
"lilypond_source": r"\relative c' { c( d e f g a g f e d c2) }",
},
]
def seed():
init_db()
# Add all exercises to the database in one transaction
with get_session() as session:
for data in SEED_EXERCISES:
exercise = Exercise(**data)
session.add(exercise)
session.commit()
print(f"Seeded {len(SEED_EXERCISES)} exercises.")
# Render a PNG for each exercise
for data in SEED_EXERCISES:
output = render_exercise(data["name"], data["lilypond_source"])
print(f"Rendered: {output}")
if __name__ == "__main__":
seed()
What does this code do?
- SEED_EXERCISES is a list of dictionaries, each representing one warmup exercise with its metadata and LilyPond notation source.
- The seed() function first adds all exercises to the database in a single commit, then iterates through the list to render each one as a PNG.
- Each exercise targets a different vocal skill: breath support, range extension, intonation, and vowel unification.
- The r"..." prefix on lilypond_source strings makes them raw strings, so backslashes in LilyPond commands like \relative are preserved.
- Save seed.py.
- Run the seed script by executing:
python seed.py
You should see output confirming 4 exercises were seeded and 4 PNGs were rendered to the output/ folder.
- Confirm the PNG files exist by running:
ls output/
You should see descending_five-note_scale.png, ascending_arpeggio.png, sustained_unison.png, and stepwise_legato.png in the output.
- Verify the database is populated by running:
python main.py list
You should see a formatted table showing all 4 exercises with their names, purposes, and vocal ranges.
Seeing duplicate exercises?
If you ran the seed script more than once, duplicate entries will appear. Delete your warmups.db file and run python seed.py again to start fresh.
help me fix duplicate exercises in my database
LilyPond rendering failed?
If the seed runs but some PNGs fail to render, check that lilypond is still accessible in your terminal. The exercises are saved to the database regardless of render success, so you can re-run rendering with:
python main.py render-all --force
help me debug LilyPond rendering errors in my seed script
✔️ Awesome, I've got everything!
Great. Double check you have saved seed.py and that all 4 PNGs are in your output/ folder.
ⓧ I'd like to double check the full code
from database import get_session, init_db
from models import Exercise
from renderer import render_exercise
SEED_EXERCISES = [
{
"name": "Descending Five-Note Scale",
"description": "Classic descending scale for warming up the voice gently.",
"purpose": "breath support",
"lowest_note": "c'",
"highest_note": "g'",
"lilypond_source": r"\relative c'' { g f e d c }",
},
{
"name": "Ascending Arpeggio",
"description": "Major triad arpeggio ascending and descending for range extension.",
"purpose": "range extension",
"lowest_note": "c'",
"highest_note": "g'",
"lilypond_source": r"\relative c' { c e g e c }",
},
{
"name": "Sustained Unison",
"description": "Long sustained note for tuning and breath control.",
"purpose": "intonation",
"lowest_note": "f'",
"highest_note": "f'",
"lilypond_source": r"\relative c'' { f1~ f }",
},
{
"name": "Stepwise Legato",
"description": "Ascending and descending legato line for vowel unification.",
"purpose": "vowel unification",
"lowest_note": "c'",
"highest_note": "a'",
"lilypond_source": r"\relative c' { c( d e f g a g f e d c2) }",
},
]
def seed():
init_db()
with get_session() as session:
for data in SEED_EXERCISES:
exercise = Exercise(**data)
session.add(exercise)
session.commit()
print(f"Seeded {len(SEED_EXERCISES)} exercises.")
for data in SEED_EXERCISES:
output = render_exercise(data["name"], data["lilypond_source"])
print(f"Rendered: {output}")
if __name__ == "__main__":
seed()
Your warmup database is now populated and every exercise has a notation image ready for reference. Next up, you will tackle the Secret Mission and add multi-voice SATB support to the system.
Secret mission
Add SATB Multi-Voice Support
Your warmup manager currently handles single-voice exercises. Real choir warmups often need four-part notation showing Soprano, Alto, Tenor, and Bass on separate staves. Extend the schema, renderer, and CLI to support full SATB notation output.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Project folder containing models.py, database.py, renderer.py, main.py, and seed.py.
- .venv virtual environment (contains SQLAlchemy and Typer packages).
- warmups.db SQLite database file.
- output/ directory with rendered PNG notation images.
Keep everything running
No action needed. Choose this if you're still actively building or plan to add more warmup exercises to your collection.
- Your project folder stays on your machine with all files intact.
- The warmups.db database retains all your exercises.
- Rendered PNGs remain in the output/ folder.
- The .venv virtual environment stays available for running CLI commands.
Pause - I'll come back to this later
Deactivate your virtual environment to free up your terminal session. All files stay on disk for when you return.
- Deactivate the virtual environment by running:
deactivate
- When you come back, reactivate with .venv\Scripts\activate (Windows) or source .venv/bin/activate (macOS/Linux).
- Your database, source files, and rendered PNGs are all preserved.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your database, rendered PNGs, source code, and virtual environment.
- Deactivate the virtual environment first by running:
deactivate
- Navigate to the parent directory that contains your project folder by running:
cd ..
- Delete the entire project folder (macOS/Linux) by running:
rm -rf warmup-manager
- On Windows, run this instead:
rmdir /s /q warmup-manager
- This removes all Python source files, the .venv virtual environment, the warmups.db database, and the output/ PNG directory in one step.
Nice Work!
Nice Work!
Nice work! You just built a complete command-line application that stores choir warmup exercises in a structured database and generates publication-quality notation images on demand.
You've learned how to:
- Build a SQLAlchemy ORM data model and connect it to a SQLite database without installing a database server.
- Create a Typer CLI application with multiple subcommands (add, list, render, render-all).
- Integrate LilyPond as an external tool via Python's subprocess module, building a notation rendering pipeline that converts stored source into cropped PNG images.
- Secret Mission: You extended the system to handle SATB multi-voice notation, preparing it for the full range of choral warmup exercises.
Ready to quiz yourself?