Add LilyPond transcriber module
- Convert chord data to LilyPond parts and PDF - Generate part files for voices I, II, III - Generate full score from template - Call LilyPond to create PDF automatically - CLI with --name, --fundamental, --template options
This commit is contained in:
parent
326ae9da1b
commit
dd637e64e2
458
src/transcriber.py
Normal file
458
src/transcriber.py
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
#!/usr/bin/env python
|
||||
"""
|
||||
LilyPond Transcriber - Convert chord data to LilyPond parts and PDF.
|
||||
|
||||
Usage:
|
||||
python src/transcriber.py --name compact_sets_1
|
||||
|
||||
Or import and use programmatically:
|
||||
from src.transcriber import transcribe
|
||||
transcribe(chords, name="my_piece")
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
NOTE_NAMES_SHARPS = [
|
||||
"c",
|
||||
"cis",
|
||||
"d",
|
||||
"dis",
|
||||
"e",
|
||||
"f",
|
||||
"fis",
|
||||
"g",
|
||||
"gis",
|
||||
"a",
|
||||
"ais",
|
||||
"b",
|
||||
]
|
||||
NOTE_NAMES_FLATS = [
|
||||
"c",
|
||||
"des",
|
||||
"d",
|
||||
"ees",
|
||||
"e",
|
||||
"f",
|
||||
"ges",
|
||||
"g",
|
||||
"aes",
|
||||
"a",
|
||||
"bes",
|
||||
"b",
|
||||
]
|
||||
|
||||
OCTAVE_STRINGS = [",,,", ",,", ",", "", "'", "''", "'''", "''''", "'''''", "''''''"]
|
||||
|
||||
DURATION_MAP = {
|
||||
1: "8",
|
||||
2: "4",
|
||||
3: "4.",
|
||||
4: "2",
|
||||
6: "2.",
|
||||
8: "1",
|
||||
}
|
||||
|
||||
|
||||
def cps_to_midi(freq):
|
||||
"""Convert frequency in Hz to MIDI note number."""
|
||||
if freq <= 0:
|
||||
return -1
|
||||
return 12 * math.log2(freq / 440.0) + 69
|
||||
|
||||
|
||||
def midi_to_pitch_class(midi):
|
||||
"""Get pitch class (0-11) from MIDI note number."""
|
||||
if midi < 0:
|
||||
return -1
|
||||
return round(midi) % 12
|
||||
|
||||
|
||||
def midi_to_octave(midi):
|
||||
"""Get LilyPond octave number from MIDI note number."""
|
||||
if midi < 0:
|
||||
return -1
|
||||
return (round(midi) // 12) - 1
|
||||
|
||||
|
||||
def freq_to_lilypond(freq, spelling="sharps", prev_pitch=None):
|
||||
"""Convert frequency to LilyPond note name.
|
||||
|
||||
Args:
|
||||
freq: Frequency in Hz
|
||||
spelling: "sharps" or "flats" (determines base preference)
|
||||
prev_pitch: Previous pitch class (for contextual spelling)
|
||||
|
||||
Returns:
|
||||
LilyPond note string (e.g., "ais''", "ees'") or "r" for rest
|
||||
"""
|
||||
if freq <= 0:
|
||||
return "r"
|
||||
|
||||
midi = cps_to_midi(freq)
|
||||
pc = midi_to_pitch_class(midi)
|
||||
octave = midi_to_octave(midi)
|
||||
|
||||
if spelling == "flats":
|
||||
note_name = NOTE_NAMES_FLATS[pc]
|
||||
else:
|
||||
note_name = NOTE_NAMES_SHARPS[pc]
|
||||
|
||||
oct_str = OCTAVE_STRINGS[octave + 4] if octave >= -4 else ",," * (-octave - 4)
|
||||
return note_name + oct_str
|
||||
|
||||
|
||||
def duration_to_lilypond(beats):
|
||||
"""Convert quarter-note beats to LilyPond duration string."""
|
||||
beats = int(round(beats))
|
||||
return DURATION_MAP.get(beats, "4")
|
||||
|
||||
|
||||
def format_cents_deviation(freq):
|
||||
"""Format cent deviation from nearest equal-tempered note."""
|
||||
if freq <= 0:
|
||||
return None
|
||||
|
||||
midi = cps_to_midi(freq)
|
||||
deviation = (midi - round(midi)) * 100
|
||||
deviation = round(deviation)
|
||||
|
||||
if deviation != 0:
|
||||
sign = "+" if deviation > 0 else ""
|
||||
return f"{sign}{deviation}"
|
||||
return None
|
||||
|
||||
|
||||
def format_dim_diff(dim_diff, ref):
|
||||
"""Format dimensional difference markup."""
|
||||
if dim_diff is None or ref is None or ref < 0:
|
||||
return ""
|
||||
|
||||
diff_str = str(abs(dim_diff))
|
||||
if dim_diff > 1:
|
||||
diff_str += "↑"
|
||||
elif dim_diff < 0:
|
||||
diff_str += "↓"
|
||||
|
||||
ref_name = ["III", "II", "I"][ref] if 0 <= ref <= 2 else ""
|
||||
|
||||
return f'_\\markup {{ \\lower #3 \\pad-markup #0.2 \\concat{{ "{ref_name}"\\normal-size-super " {diff_str}" }} }}'
|
||||
|
||||
|
||||
def generate_part(voice_data, voice_name, voice_idx, beats_per_measure=4):
|
||||
"""Generate LilyPond music string for a single voice.
|
||||
|
||||
Args:
|
||||
voice_data: List of [freq, duration_beats, ref, dim_diff] events
|
||||
voice_name: Voice name (e.g., "I", "II", "III")
|
||||
voice_idx: Voice index (0=I, 1=II, 2=III)
|
||||
beats_per_measure: Beats per measure (default 4 for 4/4)
|
||||
|
||||
Returns:
|
||||
LilyPond music string
|
||||
"""
|
||||
measures = []
|
||||
current_measure_notes = []
|
||||
beat_in_measure = 0
|
||||
last_note = None
|
||||
spelling = "sharps"
|
||||
|
||||
for event in voice_data:
|
||||
freq = event[0]
|
||||
dur_beats = event[1] if len(event) > 1 else 1
|
||||
ref = event[2] if len(event) > 2 else None
|
||||
dim_diff = event[3] if len(event) > 3 else None
|
||||
|
||||
note_str = freq_to_lilypond(freq, spelling)
|
||||
dur_str = duration_to_lilypond(dur_beats)
|
||||
|
||||
is_rest = freq <= 0
|
||||
is_tied = (note_str == last_note) and not is_rest
|
||||
|
||||
cents_dev = format_cents_deviation(freq) if not is_rest else None
|
||||
dim_markup = format_dim_diff(dim_diff, ref) if not is_rest else ""
|
||||
|
||||
note_str_full = note_str + dur_str
|
||||
|
||||
if cents_dev or dim_markup:
|
||||
markup = ""
|
||||
if cents_dev:
|
||||
markup += f'^\\markup {{ \\pad-markup #0.2 "{cents_dev}" }}'
|
||||
if dim_markup:
|
||||
markup += dim_markup
|
||||
note_str_full += markup
|
||||
|
||||
if is_tied:
|
||||
note_str_full = " ~ " + note_str_full
|
||||
else:
|
||||
note_str_full = " " + note_str_full
|
||||
|
||||
current_measure_notes.append(note_str_full)
|
||||
last_note = note_str
|
||||
|
||||
beats_this_event = int(round(dur_beats))
|
||||
beat_in_measure += beats_this_event
|
||||
|
||||
while beat_in_measure >= beats_per_measure:
|
||||
beat_in_measure -= beats_per_measure
|
||||
measures.append("".join(current_measure_notes))
|
||||
current_measure_notes = []
|
||||
|
||||
if current_measure_notes:
|
||||
measures.append("".join(current_measure_notes))
|
||||
|
||||
music_str = ""
|
||||
for i, measure in enumerate(measures):
|
||||
music_str += "{ " + measure + " }"
|
||||
if i < len(measures) - 1:
|
||||
music_str += ' \n\\bar "|" '
|
||||
|
||||
music_str += '\n\\bar "|."'
|
||||
|
||||
return music_str
|
||||
|
||||
|
||||
def generate_parts(music_data, name, output_dir="lilypond"):
|
||||
"""Generate LilyPond part files.
|
||||
|
||||
Args:
|
||||
music_data: List of voices, each voice is a list of events
|
||||
name: Name for the output (e.g., "compact_sets_1")
|
||||
output_dir: Base output directory
|
||||
"""
|
||||
includes_dir = Path(output_dir) / name / "includes"
|
||||
includes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
voice_names = ["I", "II", "III"]
|
||||
|
||||
for voice_idx, voice_data in enumerate(music_data):
|
||||
if voice_idx >= 3:
|
||||
break
|
||||
|
||||
voice_name = voice_names[voice_idx]
|
||||
part_str = generate_part(voice_data, voice_name, voice_idx)
|
||||
|
||||
part_file = includes_dir / f"part_{voice_name}.ly"
|
||||
with open(part_file, "w") as f:
|
||||
f.write(part_str)
|
||||
|
||||
print(f"Generated: {part_file}")
|
||||
|
||||
|
||||
def output_chords_to_music_data(chords, fundamental=100, chord_duration=1):
|
||||
"""Convert output_chords.json format to generic music data.
|
||||
|
||||
Args:
|
||||
chords: List of chords from output_chords.json
|
||||
fundamental: Fundamental frequency in Hz
|
||||
chord_duration: Duration of each chord in beats
|
||||
|
||||
Returns:
|
||||
List of voices, each voice is a list of [freq, duration, ref, dim_diff]
|
||||
"""
|
||||
if not chords:
|
||||
return []
|
||||
|
||||
num_voices = len(chords[0])
|
||||
|
||||
music_data = [[] for _ in range(num_voices)]
|
||||
|
||||
for chord in chords:
|
||||
for voice_idx, pitch in enumerate(chord):
|
||||
if voice_idx >= num_voices:
|
||||
break
|
||||
|
||||
frac = Fraction(pitch["fraction"])
|
||||
freq = fundamental * float(frac)
|
||||
|
||||
ref = None
|
||||
dim_diff = 0
|
||||
|
||||
event = [freq, chord_duration, ref, dim_diff]
|
||||
music_data[voice_idx].append(event)
|
||||
|
||||
return music_data
|
||||
|
||||
|
||||
def generate_score(name, template_path, output_dir="lilypond"):
|
||||
"""Generate full score .ly file from template.
|
||||
|
||||
Args:
|
||||
name: Name for the output
|
||||
template_path: Path to score template
|
||||
output_dir: Base output directory
|
||||
"""
|
||||
template = Path(template_path)
|
||||
if not template.exists():
|
||||
print(f"Error: Template not found: {template_path}")
|
||||
return False
|
||||
|
||||
score_path = Path(output_dir) / name / f"{name}.ly"
|
||||
score_text = template.read_text()
|
||||
|
||||
score_text = score_text.replace("{NAME}", name)
|
||||
score_text = score_text.replace("{COMPOSER}", "Michael Winter")
|
||||
|
||||
with open(score_path, "w") as f:
|
||||
f.write(score_text)
|
||||
|
||||
print(f"Generated: {score_path}")
|
||||
return True
|
||||
|
||||
|
||||
def generate_pdf(name, lilypond_dir="lilypond", output_dir="."):
|
||||
"""Generate PDF from LilyPond score.
|
||||
|
||||
Args:
|
||||
name: Name of the piece (LilyPond file should be {name}.ly)
|
||||
lilypond_dir: Directory containing the LilyPond file
|
||||
output_dir: Directory for PDF output
|
||||
|
||||
Returns:
|
||||
Path to generated PDF, or None if failed
|
||||
"""
|
||||
ly_file = Path(lilypond_dir) / name / f"{name}.ly"
|
||||
if not ly_file.exists():
|
||||
print(f"Error: LilyPond file not found: {ly_file}")
|
||||
return None
|
||||
|
||||
output_base = Path(output_dir) / name
|
||||
output_base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"lilypond",
|
||||
"-o",
|
||||
str(output_base),
|
||||
"-f",
|
||||
"pdf",
|
||||
str(ly_file),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"LilyPond error:\n{result.stderr}")
|
||||
return None
|
||||
|
||||
pdf_path = output_base / f"{name}.pdf"
|
||||
if pdf_path.exists():
|
||||
print(f"Generated: {pdf_path}")
|
||||
return pdf_path
|
||||
else:
|
||||
print(f"Warning: LilyPond ran but PDF not found at {pdf_path}")
|
||||
print(f"Output: {result.stdout}")
|
||||
return None
|
||||
|
||||
except FileNotFoundError:
|
||||
print("Error: lilypond command not found. Is LilyPond installed?")
|
||||
return None
|
||||
|
||||
|
||||
def transcribe(
|
||||
chords,
|
||||
name,
|
||||
fundamental=100,
|
||||
template_path="lilypond/score_template.ly",
|
||||
output_dir="lilypond",
|
||||
generate_pdf_flag=True,
|
||||
):
|
||||
"""Main transcription function.
|
||||
|
||||
Args:
|
||||
chords: Chord data (list from output_chords.json or music_data format)
|
||||
name: Name for the output
|
||||
fundamental: Fundamental frequency in Hz
|
||||
template_path: Path to score template
|
||||
output_dir: Base output directory
|
||||
generate_pdf_flag: Whether to generate PDF
|
||||
|
||||
Returns:
|
||||
Dictionary with paths to generated files
|
||||
"""
|
||||
if isinstance(chords[0][0], dict):
|
||||
music_data = output_chords_to_music_data(chords, fundamental)
|
||||
else:
|
||||
music_data = chords
|
||||
|
||||
generate_parts(music_data, name, output_dir)
|
||||
|
||||
if template_path and Path(template_path).exists():
|
||||
generate_score(name, template_path, output_dir)
|
||||
|
||||
result = {
|
||||
"parts_dir": str(Path(output_dir) / name / "includes"),
|
||||
"score_file": str(Path(output_dir) / name / f"{name}.ly"),
|
||||
}
|
||||
|
||||
if generate_pdf_flag:
|
||||
pdf_path = generate_pdf(name, output_dir)
|
||||
if pdf_path:
|
||||
result["pdf"] = str(pdf_path)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="LilyPond Transcriber")
|
||||
parser.add_argument(
|
||||
"--output-dir", default="output", help="Directory with output_chords.json"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--chords-file", default=None, help="Chords file (default: output_chords.json)"
|
||||
)
|
||||
parser.add_argument("--name", required=True, help="Name for output files")
|
||||
parser.add_argument(
|
||||
"--fundamental", type=float, default=100, help="Fundamental frequency in Hz"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--template",
|
||||
default="lilypond/score_template.ly",
|
||||
help="LilyPond template path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lilypond-dir", default="lilypond", help="Base LilyPond output directory"
|
||||
)
|
||||
parser.add_argument("--no-pdf", action="store_true", help="Skip PDF generation")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
chords_file = args.chords_file
|
||||
if chords_file is None:
|
||||
chords_file = Path(args.output_dir) / "output_chords.json"
|
||||
|
||||
if not Path(chords_file).exists():
|
||||
print(f"Error: Chords file not found: {chords_file}")
|
||||
print("Run compact_sets.py first to generate chords.")
|
||||
sys.exit(1)
|
||||
|
||||
with open(chords_file) as f:
|
||||
chords = json.load(f)
|
||||
|
||||
print(f"Loaded {len(chords)} chords from {chords_file}")
|
||||
|
||||
result = transcribe(
|
||||
chords,
|
||||
args.name,
|
||||
fundamental=args.fundamental,
|
||||
template_path=args.template,
|
||||
output_dir=args.lilypond_dir,
|
||||
generate_pdf_flag=not args.no_pdf,
|
||||
)
|
||||
|
||||
print("\nGenerated files:")
|
||||
for key, path in result.items():
|
||||
print(f" {key}: {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue