diff --git a/src/transcriber.py b/src/transcriber.py new file mode 100644 index 0000000..f0ba13f --- /dev/null +++ b/src/transcriber.py @@ -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()