Source code for jird.lilypond

"""
Code for translating jird music into lilypond code.

Lilypond can produce pdf sheet music from the lilypond code.

See https://lilypond.org/
"""
from fractions import Fraction
from itertools import chain
from math import floor, log2
from pathlib import Path
from typing import List, Tuple, Union

from jird.constants import MIDDLE_C_FREQUENCY, REST_FREQUENCY
from jird.core import (
    Chord,
    Note,
    Part,
    Piece,
    Power,
    RatioProduct,
    evaluate,
    lowest,
    prod,
    temper_note,
)

NOTE_NAMES = [
    "c",
    "cs",
    "df",
    "d",
    "ds",
    "ef",
    "e",
    "es",
    "f",
    "fs",
    "gf",
    "g",
    "gs",
    "af",
    "a",
    "as",
    "bf",
    "b",
    "bs",
]
"""
Lilypond note names to use. Currently uses nineteen notes but can be
any length. For example could use the note names from 31EDO to get
double sharps and flats, or variant with half sharps and flats (see
https://en.wikipedia.org/wiki/31_equal_temperament).
"""

DURATIONS = [
    Fraction(4, 1),
    Fraction(2, 1),
    Fraction(1, 1),
    Fraction(1, 2),
    Fraction(1, 4),
    Fraction(1, 8),
    Fraction(1, 16),
    Fraction(1, 32),
]
"""
Durations of basic lilypond notes.
"""

CENTS = r"""#(define (cents . args) #{
    ^\markup{
        \teeny
        \override #'(baseline-skip . 1.4)
        \with-color "gray"
        \center-column {
            $(reverse args)
        }
    }
#})
"""
"""
Scheme function to annotate note or chord with cent deviations.
Used to avoid duplication in the generated lilypond code.
"""

RATIOS = r"""#(define (ratios . args) #{
    _\markup{
        \teeny
        \override #'(baseline-skip . 1.4)
        \center-column {
            \with-color "blueviolet" $(reverse (cdr args))
            \with-color "orangered" $(car args)
        }
    }
#})
"""
"""
Scheme function to annotate note or chord with ratios.
Used to avoid duplication in the generated lilypond code.
"""

INDENT = "  "


[docs]def lilypond_pitch( note: Note, f0: float, base_edo: int = 12 ) -> Tuple[str, str, Union[RatioProduct, Fraction, Power]]: """ Calculate lilypond representation of pitch of `note`. Parameters ---------- note : Note Note to represent. f0 : float Frequency of 1/1 in Hz. base_edo : int EDO to use when calculating cent deviations. Defaults to 12, in which case deviations are with respect to the nearest note in twelve tone equal temperament. Returns ------- tuple of (str, str, RatioProduct or Fraction or Power) Note name, formatted cent deviation, and note frequency. Frequency is either a ratio or a power if `note` is tempered. """ if evaluate(note.frequency) == REST_FREQUENCY: return "r", "", RatioProduct(Fraction(0)) n_notes = len(NOTE_NAMES) real_frequency = evaluate(note.frequency) * f0 steps = round(n_notes * log2(real_frequency / MIDDLE_C_FREQUENCY)) octaves, note_index = divmod(steps, n_notes) # Lilypond writes unmarked c,d,...,b for octave *below* middle C octaves += 1 base_note_name = NOTE_NAMES[note_index] octave_marks = abs(octaves) * ("'" if octaves > 0 else ",") note_name = base_note_name + octave_marks cent_deviation = note.cents - temper_note(note, edo=base_edo).cents cent_deviation_str = f"{round(cent_deviation):+}" return note_name, cent_deviation_str, note.frequency
def _factorize(n: int) -> Tuple[int, int]: """Factorize a number into the largest power of two it contains and the remaining factor.""" assert n > 0 count = 0 while n % 2 == 0: count += 1 n //= 2 power_of_two = 2**count return power_of_two, n
[docs]def lilypond_duration(note: Note) -> str: """ Get lilypond representation of duration of `note`. Parameters ---------- note : Note Note to find duration of. Returns ------- str Template of lilypond representation of `note` duration, ready to be populated with a lilypond note pitch or chord. """ duration = Fraction(evaluate(note.duration)) assert duration > 0 # If denominator of note duration is not a power of two, write note as a # tuplet using the next smallest power of two. So for example a note with a # duration 1/3 gets written using a 3/2 tuplet, duration 1/5 a 5/4 tuplet, # and so on. power_of_two, odd_factor = _factorize(duration.denominator) rescale = 2 ** floor(log2(odd_factor)) # Write remaining note as sum of 1/2**n durations to_divide = Fraction(duration.numerator, power_of_two * rescale) coeffs = {} for d in DURATIONS: coeffs[d], to_divide = divmod(to_divide, d) assert to_divide == 0, f"Could not represent {note} using durations: {DURATIONS}" # Lilypond writes a note lasting d beats as 4/d, so c1 is four beats, # c2 is two beats, c4 is one beat, c8 is half a beat, etc. # Lilypond notes to tie together: parts = list( chain.from_iterable(v * [f"{{0}}{4 / k}"] for k, v in coeffs.items() if v != 0) ) # First argument, {0}, in template is for note name or chord # Second argument, {1}, in template is for annotations. if len(parts) == 1: base = parts[0] + "{1}" else: # Tie components to make full duration # Annotate the first of the tied notes base = f"{parts[0]}~" + "{1}" + "~".join(parts[1:]) if odd_factor == 1: return base # Use tuplet if required return r"\tuplet" + f" {odd_factor}/{rescale} " + "{{" + base + "}}"
[docs]def lilypond_note(note: Note, f0: float) -> str: r""" Find complete lilypond representation of `note`. Parameters ---------- note : Note Note to represent. f0 : float Base frequency in Hz. Returns ------- str Lilypond representation of `note` including note name, duration, and annotations with cent deviation and ratios. Examples -------- Lilypond representation of the just major third above middle C >>> from jird.core import parse >>> note = parse("5/4:1")[0][0] >>> print(lilypond_note(note, f0=261.63)) e'4 $(cents "-14") $(ratios "1" "5/4") The `$(cents "-14")` is lilypond syntax to call a Scheme function called `cents` on `"-14"`. This Scheme function is defined at the top of the generated lilypond file to allow easy modification of the cent deviation formatting. Likewise for ratios. """ duration = lilypond_duration(note) note_name, cent_deviation, frequency = lilypond_pitch(note, f0) if note_name == "r": # Do not need ties for rests return duration.format(note_name, "").replace("~", " ") cents = f'$(cents "{cent_deviation}")' # Ratios may not be present if note has been tempered ratios = "" if isinstance(frequency, RatioProduct): parts = frequency.ratios if len(parts) > 1: base = evaluate(prod(parts[:-1], Fraction(1))) freq = evaluate(parts[-1]) else: base = 1 freq = evaluate(frequency) ratios = f'$(ratios "{base}" "{freq}")' return duration.format(note_name, f" {cents} {ratios} ").strip()
[docs]def lilypond_chord(chord: Chord, f0: float) -> str: r""" Find lilypond representation of `chord`. Parameters ---------- chord : Chord Chord to represent. f0 : float Base frequency in Hz. Returns ------- str Lilypond representation of `chord` with note names, duration, and annotations with cent deviations and ratios. Examples -------- Lilypond representation of just major triad >>> from jird.core import parse >>> chord = parse("<1 5/4 3/2>:4")[0][0] >>> print(lilypond_chord(chord, f0=440)) <a' cs'' e''>1 $(cents "+0" "-14" "+2") $(ratios "1" "1" "5/4" "3/2") """ template = lilypond_duration(chord[0]) note_names, cent_deviations, frequencies = zip( *(lilypond_pitch(note, f0) for note in sorted(chord, key=lambda x: x.cents)) ) chord_body = f"<{' '.join(note_names)}>" cent_deviations_str = " ".join(f'"{x}"' for x in cent_deviations) cents = f"$(cents {cent_deviations_str})" # Ratios may not be present if chord has been tempered ratios = "" if all(isinstance(x, RatioProduct) for x in frequencies): parts = [x.ratios for x in frequencies] common = _count_leading_shared(parts) base = evaluate(prod(parts[0][:common], Fraction(1))) chord_freqs = [evaluate(prod(x[common:], Fraction(1))) for x in parts] freqs = " ".join(f'"{x}"' for x in chord_freqs) ratios = f'$(ratios "{base}" {freqs})' return template.format(chord_body, f" {cents} {ratios} ").strip()
def _count_leading_shared(parts: List[Tuple[Fraction, ...]]) -> int: """Find number of elements in common at the start of each tuple in `parts`.""" shared = [len(set(x)) == 1 for x in zip(*parts)] try: count = shared.index(False) except ValueError: count = len(shared) return count
[docs]def lilypond_part(music: Part, f0: float, *, indent_level: int) -> str: r""" Lilypond representation of one part. Parameters ---------- music : Part Part to be represented. f0 : float Base frequency in Hz. indent_level : int Number of levels to indent part in generated lilypond. Returns ------- str Lilypond representation of part in `music`. Examples -------- Lilypond representation of two notes and a chord >>> from jird.core import parse >>> part = parse("1:1 5/4:1 <1 5/4 3/2>:2")[0] >>> print(lilypond_part(part, f0=440, indent_level=0)) \new Staff{ a'4 $(cents "+0") $(ratios "1" "1") cs''4 $(cents "-14") $(ratios "1" "5/4") <a' cs'' e''>2 $(cents "+0" "-14" "+2") $(ratios "1" "1" "5/4" "3/2") } Bass clef is used for parts containing low notes >>> part = parse("1/4:4")[0] >>> print(lilypond_part(part, f0=440, indent_level=0)) \new Staff{ \clef bass a,1 $(cents "+0") $(ratios "1" "1/4") } """ body = [ lilypond_note(x, f0) if isinstance(x, Note) else lilypond_chord(x, f0) for x in music ] start = [indent_level * INDENT + r"\new Staff{"] end = [indent_level * INDENT + "}"] if lowest(music) <= 1 / 2: body = ["\\clef bass", *body] lines = start + [(indent_level + 1) * INDENT + x for x in body] + end return "\n".join(lines)
[docs]def lilypond_music(music: Piece, f0: float) -> str: r""" Lilypond representation of music containing multiple simultaneous parts. Parameters ---------- music : Piece Music to represent f0 : float Base frequency in Hertz. Returns ------- str Complete lilypond representation of `music`. Examples -------- The output contains lilypond headers and Scheme functions for annotations with cent deviations and ratios. >>> from jird.core import parse >>> part = parse("1:4") >>> print(lilypond_music(part, f0=440)) \version "2.22.2" \language "english" <BLANKLINE> #(define (cents . args) #{ ^\markup{ \teeny \override #'(baseline-skip . 1.4) \with-color "gray" \center-column { $(reverse args) } } #}) <BLANKLINE> #(define (ratios . args) #{ _\markup{ \teeny \override #'(baseline-skip . 1.4) \center-column { \with-color "blueviolet" $(reverse (cdr args)) \with-color "orangered" $(car args) } } #}) <BLANKLINE> \score { << \new Staff{ a'1 $(cents "+0") $(ratios "1" "1") } >> \layout{} } """ parts = [lilypond_part(part, f0, indent_level=2) for part in music] lines = [ '\\version "2.22.2"', '\\language "english"', "", CENTS, RATIOS, "\\score {", INDENT + "<<", *parts, ] + [INDENT + ">>", INDENT + r"\layout{}", "}"] return "\n".join(lines)
[docs]def write_lilypond_music( music: Piece, f0: float, output_path: Union[str, Path] ) -> None: """ Write lilypond representation of `music` to a file. Parameters ---------- music : Piece Music to represent. f0 : float Base frequency in Hz. output_path : str or Path Filename for writing output. """ lilypond_text = lilypond_music(music, f0) with open(output_path, "w", encoding="utf8") as f: f.write(lilypond_text)