From 556b92eae441bd51d52bb7fc4741580cb95fe3cf Mon Sep 17 00:00:00 2001 From: Matteo Date: Fri, 2 Oct 2026 14:28:40 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=96=20Release=200.5.0:=20Epic=20M,?= =?UTF-8?q?=20notation=20you=20can=20trust?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-10-01 review found that what the note boards didn't measure was broken somewhere. M0 measures it (notation board: lyrics, chord symbols, dynamics, clefs at their places; stems/beams against the source engravers; LilyPond lyric and playback oracles; one Python test per confirmed bug), and M1-M10 fix it: - M1/M2 MusicXML read at the / cursor; written valid (escaped text, always, encodings). - M5 LilyPond constructs no longer dropped (afterGrace, grace chords, tremolo, \partial N*M, beat structure, text scripts, placement, marks). - M6 note arrays: per-part velocity, direction dynamics, sounding pitch, grace chords; Music::Transposition. - M7 chord symbols as steps; LilyPond chordmode at each beat. - M4 stems and beams drawn by LilyPond's rules where a source left them. - M8 writers: church modes, tempo once, marks at their beat, mid-bar clefs, ABC clefs/hairpins, kern rests and *staff, MIDI channels. - M3 lyrics sung where LilyPond sings them, through every format. - M9 retrograde carries keys/clefs; transpose prefers fewer accidentals. - M10 CLI writes every movement; warnings; -I; diff compares timing. Version 0.5.0 in Cargo.toml (and Cargo.lock); the changelog's [Unreleased] becomes [0.5.0] - 2026-10-02; roadmap, devlog, import-export (measured support) and the CLI docs updated. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 +- Cargo.toml | 5 +- README.md | 9 +- docs/changelog.md | 251 +++++- docs/cli.md | 26 +- docs/devlog.md | 517 ++++++++++++ docs/import-export.md | 81 +- docs/python-api.md | 13 +- docs/roadmap.md | 60 +- src/adapters/abc_to_ir.rs | 291 +++++-- src/adapters/dynamics_velocity.rs | 44 +- src/adapters/humdrum_to_ir.rs | 173 +++- src/adapters/ir_to_abc.rs | 305 +++++-- src/adapters/ir_to_humdrum.rs | 352 ++++++-- src/adapters/ir_to_ly/emit.rs | 365 +++++---- src/adapters/ir_to_ly/helpers.rs | 11 + src/adapters/ir_to_ly/lyrics.rs | 456 ++++++----- src/adapters/ir_to_ly/maps.rs | 126 +-- src/adapters/ir_to_ly/mod.rs | 38 +- src/adapters/ir_to_ly/music_emit.rs | 347 ++++++-- src/adapters/ir_to_ly/parts.rs | 151 ++-- src/adapters/ir_to_ly/tests.rs | 86 +- src/adapters/ir_to_midi.rs | 112 ++- src/adapters/ir_to_mxml/direction.rs | 46 +- src/adapters/ir_to_mxml/helpers.rs | 24 +- src/adapters/ir_to_mxml/mod.rs | 74 +- src/adapters/ir_to_mxml/note.rs | 191 +++-- src/adapters/ir_to_mxml/part.rs | 122 ++- src/adapters/ir_to_mxml/tests.rs | 18 +- src/adapters/ly_to_ir/apply.rs | 60 +- src/adapters/ly_to_ir/chord_mode.rs | 80 +- src/adapters/ly_to_ir/consume.rs | 316 ++++--- src/adapters/ly_to_ir/lyrics.rs | 802 +++++++++++++----- src/adapters/ly_to_ir/mod.rs | 24 +- src/adapters/ly_to_ir/modifiers.rs | 88 ++ src/adapters/ly_to_ir/music.rs | 310 +++++-- src/adapters/ly_to_ir/state.rs | 150 ++-- src/adapters/ly_to_ir/tests.rs | 397 ++++++++- src/adapters/ly_to_ir/walk.rs | 170 ++-- src/adapters/midi_to_ir/mod.rs | 43 +- src/adapters/midi_to_ir/voices.rs | 23 +- src/adapters/mod.rs | 257 ++++++ src/adapters/mxml_to_ir/direction.rs | 55 +- src/adapters/mxml_to_ir/mod.rs | 90 +- src/adapters/mxml_to_ir/note.rs | 155 +++- src/adapters/mxml_to_ir/part.rs | 299 +++++-- src/adapters/mxml_to_ir/tests.rs | 231 ++++++ src/ir/annotation.rs | 3 + src/ir/articulation.rs | 9 +- src/ir/beams.rs | 1089 ++++++++++++++++--------- src/ir/direction.rs | 22 + src/ir/harmony.rs | 633 ++++++++++++-- src/ir/lift.rs | 52 +- src/ir/lower/build.rs | 2 + src/ir/lower/state.rs | 1 + src/ir/lower/walk.rs | 4 + src/ir/measure.rs | 72 ++ src/ir/music.rs | 3 + src/ir/note.rs | 41 + src/ir/timeline.rs | 25 +- src/lytk/_core.pyi | 15 +- src/lytk/cli.py | 255 ++++-- src/navigation.rs | 31 + src/python.rs | 36 +- src/representations/event_sequence.rs | 38 +- src/representations/note_array.rs | 102 ++- src/representations/piano_roll.rs | 4 +- src/transforms/retrograde.rs | 211 ++++- src/transforms/transpose.rs | 23 +- tests/abc_standard.rs | 6 +- tests/common/abc_oracle.rs | 37 +- tests/common/mod.rs | 146 ++++ tests/common/smf.rs | 17 + tests/fidelity.rs | 76 +- tests/humdrum_roundtrip.rs | 54 ++ tests/lilypond_oracle.rs | 242 +++++- tests/notation_fidelity.rs | 394 +++++++++ tests/round_trip.rs | 4 +- tests/test_cli.py | 72 ++ tests/test_representations.py | 2 +- tests/test_review_regressions.py | 532 ++++++++++++ 81 files changed, 9610 insertions(+), 2490 deletions(-) create mode 100644 tests/notation_fidelity.rs create mode 100644 tests/test_review_regressions.py diff --git a/Cargo.lock b/Cargo.lock index b444256..c46846f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,12 +457,13 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lytk" -version = "0.4.0" +version = "0.5.0" dependencies = [ "cc", "criterion", "midly", "musicxml", + "musicxml_internal", "num", "numpy", "proptest", diff --git a/Cargo.toml b/Cargo.toml index 63eefc2..26c3466 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lytk" -version = "0.4.0" +version = "0.5.0" edition = "2021" # Floor set by our oldest dependency (clap 4.6). Matters because the PyPI sdist # is compiled on the user's own toolchain. @@ -46,6 +46,9 @@ serde_json = "1" thiserror = "2" midly = "0.5" musicxml = "1.1.2" +# The element tree the `musicxml` crate serializes from: lytk renders it +# itself, escaping text and attribute values (the crate writes them raw). +musicxml_internal = "1.1.2" numpy = "0.22" # Bounded MXL (ZIP) decompression: we unzip `.mxl` ourselves with a size cap and # hand plain XML to the `musicxml` crate, bypassing its unbounded, panic-prone diff --git a/README.md b/README.md index 1472a1d..db1ea8c 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,12 @@ from the command line. repeats and voltas, cadenzas, lyrics, chord names and figured bass. It writes them back out too, in any of LilyPond's 12 note-name languages. - **Conversions are measured, not assumed.** Every round trip (LilyPond, - MusicXML, ABC, `**kern`, MIDI) is checked in CI against a test corpus for note - counts, pitches, onsets and durations. The results can only improve from one - release to the next. + MusicXML, ABC, `**kern`, MIDI) is checked in CI against a test corpus for + notes (onsets, durations, sounding pitches), lyrics, chord symbols, dynamics + and clefs at their places; stems and beams against the engravers that wrote + the files; LilyPond output against LilyPond itself. The results can only + improve from one release to the next + ([the numbers](docs/import-export.md#what-survives-a-conversion-measured)). - **Built for machine learning.** Encoders for note arrays, piano rolls and Performance-RNN event sequences, objective metrics from the muspy family, datasets over folders or JSON Lines records with deterministic (or the diff --git a/docs/changelog.md b/docs/changelog.md index 51297d4..e75a5b7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,254 @@ engineering notes are in the [development log](devlog.md). ## [Unreleased] +## [0.5.0] - 2026-10-02 + +Notation you can trust. A review found that what the note boards didn't +measure was broken somewhere, so 0.5.0 measures it — lyrics, chord +symbols, dynamics and clefs at their places, stems and beams against the +engravers that wrote the files, LilyPond output against LilyPond's own +MIDI — and fixes what the measurements showed. MusicXML is read where +`` and `` place the notes and written valid; LilyPond +lyrics are sung where LilyPond sings them and written so that LilyPond +sings them back; stems and beams follow LilyPond's rules and are drawn only +where a source left them open; chord symbols keep their beat and their +extensions; the LilyPond reader no longer drops constructs without a word; +note arrays give the velocities and sounding pitches MIDI export plays; ABC, +Humdrum and MIDI keep much more of the notation; `retrograde` and +`transpose` keep keys right; and the CLI writes every movement. + +### Fixed + +- MusicXML is read where `` and `` place the notes, not + in file order: a voice that enters mid-bar (MuseScore's `` + ``) no longer starts on beat 1 and no longer overfills another + voice (bars of 9/8 in 6/8 in real files). Chord symbols and figured bass + sit at their beat, not at the start of the bar. +- A second `` in a MusicXML bar (a clef change between notes) + no longer erases the bar's key and time signature. +- MusicXML output always declares `` (it was missing when a + score had no explicit time, key or clef, as LilyPond without `\time` and + every Humdrum input, so other programs timed every note wrong) and + escapes text: a title or lyric with `&` or `<` made XML nothing could + read. Dotted short notes keep their exact duration, and `` after + a voice with grace notes no longer goes back past the bar. +- MusicXML input: entities (`&`, `é`) are decoded and line breaks + inside text kept; ISO-8859-1 and UTF-16 files are read; a file without + `` (lytk 0.4.0 wrote some) is read with the divisions its + notes imply. +- ABC, Humdrum and MIDI text in Latin-1 is decoded (ABC files were + refused, Humdrum and MIDI text mangled). +- MuseScore 4's glyph names in part names read as accidentals: + `BaccidentalFlat Trumpet` is `B♭ Trumpet`. +- Humdrum output writes a spacer as an invisible rest (`4ryy`) instead of + null tokens, which mean "the note before goes on" and moved a voice that + enters mid-bar back to beat 1, and gives each spine its own staff's clef. + Humdrum input finds a pickup bar on its longest spine, not the leftmost. + +- LilyPond input that was misread without a word: `\afterGrace c2 { d16 e }` + (the main note was taken for the grace and the graces were timed), + `\grace 8` and chords inside grace blocks (a timed chord, or notes + one after another), the marks of grace notes (beams, slurs), single-note + and two-note `\repeat tremolo` (the first was lost with the note after + it, the second read as a volta repeat), `\partial 8*3` (its multiplier), + `\time 3,2 5/8` (the signature was dropped), text scripts + (`c^"dolce"`), `^`/`_` on articulations, slurs, fermatas, dynamics and + fingerings, `\once \stemUp` (it stayed on), `\slurUp`/`\slurDown`, + `\mark "Intro"`, `\mark \markup`, `\mark \default`, `\textMark`, + `\sectionLabel`, `\jump`, `\fine`, `\segnoMark`, `\codaMark`. `-_` is + portato, not tenuto. +- The LilyPond writers write placement (`^.`, `_\fermata`, `^(`). +- Note arrays, piano rolls, event sequences, metrics and `Score.notes()`: + a dynamic in one part no longer carries into the next part; dynamics + written as MusicXML directions are played (every velocity was 64); grace + chords take no time (they delayed every later note). +- Transposing instruments survive the Music tree, ABC (`transpose=`) and + Humdrum (`*ITrd-1c-2`), and the Music-path LilyPond writer writes + `\transposition`. Humdrum output writes key, meter, clef and + transposition changes after the first bar (only the first bar's were + written). +- `MusicDocument.to_score()` and `Score.to_music_document()` keep the + LilyPond diagnostics. +- LilyPond chord names. The writer put the duration after the modifiers + (`a:71` for `a1:7`), spread a bar's chords evenly whatever their beats, + sized bars by the meter (after a pickup every chord came early), wrote + roots in Dutch whatever the output language, a no-chord as a major chord, + and dropped added and altered steps. Each chord is now written at its + beat as `root duration :modifiers /bass` (`g4:7.9-`), in the output + language, and a no-chord as a rest (LilyPond prints N.C.). The + LilyPond-to-LilyPond route (the CLI's `convert a.ly -o b.ly`) dropped + every chord symbol; it writes them in a ChordNames line. The reader + takes a chord-mode rest as a no-chord, reads scaled lengths + (`c1*3/4`), and a chord-mode block used as notes gives its roots only + (the bass of `f4:maj7/e` was a second note, doubling the bar). +- Chord-symbol suffixes are read as the chord's steps and matched to the + nearest MusicXML kind with degrees, in LilyPond (`c:9^7`, `c:m7+`, + `c:3.5.9`) and lead-sheet text (ABC): `7#9`, `add9`, `13b9`, `m(maj7)`, + `6/9`, `7sus4` were all read as major. +- ABC: a quoted string is a chord symbol only when it reads as one + (`"Fine"` was an F major chord), `"N.C."` is a no-chord, double-flat and + double-sharp roots are kept, and a staff whose bars all have two voices + is written (it was dropped as silent). +- MusicXML: harmonies one after another before a note change during that + note, as the MusicXML test suite describes (71g), instead of all sitting + at its start; the functional kinds (Neapolitan, Italian, French, German, + pedal, Tristan) are written back (they became `other`). + +- Stems as LilyPond draws them. The middle line was found in semitones (A4 + counted as the treble middle line; bass and C clefs off too). A beam + group now takes one direction from its farthest note, a chord from its + outermost notes, two voices on a staff point up and down, grace notes + up, and a whole note has none. +- Beams as LilyPond draws them: by meter (3/4 eighths as one group, 3/8 and + 2/8 the whole bar, 5/8 as 3+2, `3+2/8` by its terms, 6/8 and 6/4 by the + dotted beat), by the beam's shortest note (sixteenths go by the beat in + 4/4), ended by rests, quarters and gaps (`6/8 c8 r d` and `2/2 c8 d4 e8` + were beamed across), placed from where a pickup sits in its bar, with + secondary beams and hooks (a dotted rhythm's sixteenth hooks back), and + `c16[ d e f]` gets its second beam. +- LilyPond `\noBeam` and `\set autoBeaming = ##f` are read; a `\stemDown` + written for one bar no longer lasts into the next. +- MusicXML: a file that beams decides every note's beaming, as MuseScore + reads it (unbeamed notes stay unbeamed). +- ABC beams: notes written together are beamed, and beamed notes are + written together (every note had a space after it). Humdrum `L`, `J`, + `K`, `k` are read and written. +- `retrograde` turns beams round (it wrote `e''16] d''16 c''8[`, which + LilyPond rejects). + +- LilyPond output: church modes keep their tonic (D dorian was written + `\key c \dorian`, two flats); a piano part's tempo is written once (it + was in both staves and read back twice); `\tempo`, `\mark` and breaks + sit at their beat, not at the bar's start; a rehearsal mark keeps its + text (`\mark "Intro"` came back as A). +- The LilyPond-to-LilyPond route (the music tree) wrote a dynamic, hairpin + or text that falls between notes after the note before it, which then + carried it (a MusicXML `ff` on beat 2 landed on beat 1); it writes them + as `<>\ff` at their moment, escapes their text and keeps rehearsal marks, + D.C./D.S., segno, coda, ottavas and breaks (all were dropped). +- LilyPond input: `<>` takes no time and its marks are directions at that + moment (it was an empty chord one beat long, pushing every later note). +- A clef change inside a bar keeps its place, through every reader and + writer (it moved to the bar's start: the notes before it were read in + the new clef). +- A MusicXML D.C. or D.S. with words was written twice by every writer. +- MusicXML output writes the placement of articulations, ornaments and + slurs (`^.`, `_(`). +- ABC output: hairpin ends match their starts (a diminuendo was closed as a + crescendo), clefs are written (`K:… clef=bass`, `[K:clef=…]`), and + fingerings and bowings are written and read (`!1!`, `!upbow!`, `u`, `v`). +- Humdrum output: a voice absent from a bar, or ending early, rests there + invisibly (null tokens carried its last note on, which kern reads as + longer notes), every spine says its `*part` and `*staff`, and + articulations and fermatas are written and read (`'`, `` ` ``, `^`, + `^^`, `~`, `;`). +- MIDI output: parts with different programs never share a channel (a + MuseScore file's flute and choir both asked for channel 1 and played as + one instrument), and the title, composer and copyright are written (and + read back). + +- Lyrics from LilyPond are sung where LilyPond sings them (31 of 31 cases + checked against LilyPond 2.22's own MIDI; 11 before): + - slurs and ties are melismas whatever the beaming, manual beams under + `\autoBeamOff`; chords too; + - `__` extends the syllable before (it used two notes), `~` joins words + on one note (`a‿b`), `_` in a word is a space, punctuation stays in its + word (`don't` was two syllables), a lone `-` is a word, `la4` has no `4`; + - `\skip`, `\repeat unfold`, `\set stanza`, `\set ignoreMelismata`, + unquoted `\lyricsto mel`, a lyric variable after `\addlyrics`, and a + Lyrics line without `\lyricsto` (placed by its durations); + - two `\addlyrics` or `\lyricsto` lines are two verses (they were one, + interleaved, or the second replaced the first), in source order; + - `\lyricsto "s"` follows the voice named "s", not every voice of its + staff; a `<< { } \\ { } >>` passage is new voices, which lyrics skip; + music after a `\new Voice` stays in it; lyrics on a NullVoice go on the + notes that start with its notes. +- LilyPond output of lyrics: one token per note under `\set + ignoreMelismata = ##t`, so a slurred pair from MusicXML no longer loses + its second syllable; a verse starting late stays in place; the lyrics' + voice goes on through multi-voice bars (`<< { \voiceOne … } \new Voice { + \voiceTwo … } >>`) instead of skipping them; another voice that sings, + or a voice on another staff, sings on a NullVoice; elisions are written + `a~b`, verse names as `\set stanza`, and any word LilyPond would misread + (a digit anywhere: `dominant-11th`) is quoted. LilyPond sings 710 of 710 + syllables of the test files where lytk has them. The LilyPond-to-LilyPond + route does the same with `\addlyrics`. +- MusicXML lyrics: elided syllables keep both words (`my‿a`), a verse named + by `name` or a `number` such as `chorus` is its own verse (several were + verse 1), `` ends an extender instead of starting + one, a lyric on a later chord note is kept (on the chord's first), and the + writer writes elisions and verse names. +- ABC lyrics: a verse starting mid-line keeps its leading `*`s, a verse + with nothing on a line keeps its `w:` line (later verses were renumbered), + every music line gets its `w:` lines, a `w:` line is sung on the music + line above it (ABC 2.1; it went on from the last one, so lyrics under bar + 47 landed on bar 1), a word hyphenated over two `w:` lines stays one, and + `- _ * ~ | % \` in a syllable are escaped. +- MIDI lyrics: a syllable after one ending in `-` continues its word + (every syllable was a beginning or alone); a track carries one verse + (verses were mixed note by note). +- Humdrum: `**text` (and `**silbe`) spines are read onto their kern spine + and written, one per verse. +- LilyPond output: a dynamic LilyPond doesn't define (`pppppp`) is written + `-#(make-dynamic-script "pppppp")` (`\pppppp` stopped LilyPond), and read + back; `\sfp` and `\n` are read. + +- `retrograde` moves key, meter, transposition and clef changes with + their music (bar 1 kept the first key over what had been the last bar), + starts each bar in the clef its music ended in and mirrors clef changes + inside it, and turns words round (a word's last syllable begins it). +- `transpose` by semitones picks the key with fewer accidentals: G major up + 6 is D-flat (5 flats), not C-sharp (7 sharps). + +- CLI: `convert` from LilyPond to LilyPond, the transforms, `abs2rel` and + `rel2abs` write every movement (they kept the first, silently); the + LilyPond reader's findings are printed on stderr; a folder conversion is + refused when two inputs would write one output (`a.mid`, `a.midi`); + `positions` counts no time for grace chords. + +### Added + +- CLI: `-I`/`--include-path` on every command that reads a score. +- `Note.lyric_syllables` in Python: each syllable's verse, name, syllabic, + extender and elision (`Note.lyrics` gives the texts only). Rust: + `LyricSyllable.name`. +- `Direction.clef`: a clef change inside the bar, at its position (a bar's + opening clef stays in its attributes). `KeySignature::tonic()`, + `RehearsalMark::lilypond_default(n)`. +- `Annotation::NoAutoBeam` in the Music tree (a note whose beaming the + source decided), carried by the lift and the lowering with the source's + beams; `VoiceElement::notes()`/`notes_mut()`. +- Rust: chord symbols as steps, `ir::harmony::{chord_steps, + kind_and_degrees, ly_chord_steps, lead_sheet_chord_steps, + parse_chord_suffix, lead_sheet_suffix, ly_chord_modifiers}`, and + `Measure::content_length`. + +### Changed + +- `lytk diff` compares every note's onset, duration and sounding pitch, + not only the pitches (`--json` adds `notes_equal`). +- Readers keep only the stems and beams a source states: LilyPond and + MIDI input no longer carry inferred ones (in `to_dict`, navigation, the + Music tree). The writers that spell them out (MusicXML, ABC, Humdrum) + engrave what the source left open when they write, and the LilyPond + writer writes a stem command only where the source's stem isn't the one + LilyPond draws (it froze every inferred stem: `\stemDown a8[ b c d]`, + `\stemDown` in the upper of two voices, `\stemNeutral` before rests). +- Note arrays (and everything built on them: piano rolls, event + sequences, metrics, datasets, `Score.notes()`) give **sounding pitch**: a + B♭ clarinet's written D is a C, as MIDI export plays it. + `to_note_array(..., pitch="written")` gives written pitch. +- Note-array velocities are those `to_midi` plays: LilyPond's dynamics + table (p 69, mf 86, f 95), and **90** without a dynamic (it was 64, on a + different table). Dataset caches refresh by themselves (their key holds + lytk's version). +- MusicXML output declares the MusicXML 4.0 document type (it said 3.0). +- LilyPond: an acciaccatura or appoggiatura is read as grace notes with a + slur to their main note, and the writers write grace notes as `\grace` + or `\slashedGrace` with their slurs: they wrote every grace as + `\acciaccatura`/`\appoggiatura`, adding a slur that a MusicXML source + doesn't have. + ## [0.4.0] - 2026-09-28 LilyPond source you can inspect and edit, and datasets for curated @@ -577,7 +825,8 @@ First public release. newer. - MIT licence. -[Unreleased]: https://github.com/CSCPadova/lytk/compare/v0.4.0...HEAD +[Unreleased]: https://github.com/CSCPadova/lytk/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/CSCPadova/lytk/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/CSCPadova/lytk/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/CSCPadova/lytk/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/CSCPadova/lytk/compare/v0.1.0...v0.2.0 diff --git a/docs/cli.md b/docs/cli.md index 5b373a2..5f43651 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -73,9 +73,20 @@ is reported and skipped, and the exit status is 1 if any failed. lytk convert corpus/ -o out/ -f midi -j 8 ``` -**Several movements.** A LilyPond file with several `\score` blocks writes the -first movement to `OUTPUT` and the others next to it, as `NAME_02.EXT`, -`NAME_03.EXT`, …. Several movements can't go to stdout. +**Several movements.** A LilyPond file with several `\score` blocks, or an ABC +file with several tunes, writes the first movement to `OUTPUT` and the others +next to it, as `NAME_02.EXT`, `NAME_03.EXT`, …, whatever the output format +(LilyPond included), and so do the transforms below. Several movements can't +go to stdout. + +**Reading LilyPond.** What the reader finds (syntax errors, commands it does +not read) goes to stderr, one per line as `FILE:LINE:COLUMN: SEVERITY: MESSAGE +[CODE]`, as `lytk check` prints it; the conversion goes on. `-I DIR` +(`--include-path`, repeatable) follows `\include` files from `DIR` (after the +file's own directory); every command that reads a score takes it. + +**Folders** are refused, before anything is written, when two inputs would +write one output (`a.mid` and `a.midi`). ## transpose @@ -190,10 +201,11 @@ Writes each part to its own file, `DIR/_.`, in `FORMAT` lytk diff A B [--json] [--from FORMAT] ``` -Compares two scores by what they sound: the number of parts, the number of -notes and the multiset of pitches. It exits with status 0 when they match and -1 when they differ, so it can gate a pipeline. `--json` prints `equal`, -`parts`, `note_count` and `pitch_multiset_equal`. +Compares two scores by what they sound: the number of parts, and every note's +onset, duration and sounding pitch (dynamics, notation and layout aside). It +exits with status 0 when they match and 1 when they differ, so it can gate a +pipeline. `--json` prints `equal`, `parts`, `note_count`, +`pitch_multiset_equal` and `notes_equal`. ```bash lytk convert song.ly -o song.xml && lytk diff song.ly song.xml diff --git a/docs/devlog.md b/docs/devlog.md index 686e62b..d603e8d 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -3,6 +3,523 @@ Dated engineering notes, newest first. The release history is in [changelog.md](changelog.md). +## 2026-10-02 — Epic M: M10 (CLI) + +- **Movements.** `convert` ly→ly went through `from_lilypond_music` (first + movement) and every transform through `read_score` (first). One path now: + `_transform` reads every movement (`_read_music_movements` for ly→ly, + `_read_movements` otherwise, stdin included), applies the transform (the + identity for `convert`) and `_write_movements` writes `OUTPUT`, + `NAME_02.EXT`, …; `abs2rel`/`rel2abs` use it too. +- **Findings.** `_report` prints a LilyPond source's diagnostics on stderr + as `lytk check` does (`FILE:LINE:COL: SEVERITY: MESSAGE [CODE]`); the + movement readers print the file's once. Folder conversions don't (one + error line per failing file stays the rule there). +- **`-I`/`--include-path`** (as `flatten` had it) on every reading command, + through to the folder workers' tasks (no module state, so it works under + any process start method). +- **Folders** are refused before anything is written when two inputs map to + one output. +- **`diff`** compares sorted (onset, duration, sounding pitch) rows of the + note arrays; **`positions`** treats a chord of grace notes as a grace + (`Chord` has no `is_grace`; `_is_grace` looks at its notes). + +Tests: `tests/test_cli.py` `TestEveryMovement`, `TestReading`, +`TestInspection` (8). Next: M11, the release. + +## 2026-10-02 — Epic M: M9 (transforms) + +- **Retrograde.** Content travelled backwards but attributes stayed on the + bar shells, so a reversed score opened with the first key over the old + last bar. `in_force` records per bar the key, meter, transposition and + each staff's clef at its start and end (mid-bar clef directions + included); `reattribute` gives reversed bar `i` what bar `n-1-i` had, + written only where it changes from the bar before. A bar opens in the + clef its music ended in; a clef change at `x` becomes one at `len - x` + back to the clef before it (a staff without a clef is treble). Syllabic + Begin/End swap on reversed notes, in the Score and the Music tree. + Directions and harmonies keep their offsets within the bar (a hairpin's + direction under reversal is not obvious; left as it was). +- **Transpose.** The chromatic key table's semitone 1 was C♯ (7 sharps); + now D♭ (5 flats). F♯/G♭ (6 each) stays F♯, B stays B. The diatonic mode + keeps the interval's spelling, as asked. + +The review repros are all plain tests now; their xfail helper is gone. +Next: M10. + +## 2026-10-02 — Epic M: M3 (lyrics) + +LilyPond itself was the reference throughout: each rule below was checked by +compiling a case with LilyPond 2.22 and reading the lyric events of its MIDI +(`tests/lilypond_oracle.rs`, now 31 cases, all aligned; 11 before). + +- **Reader lexer** (`ly_to_ir/lyrics.rs`, rewritten). A word is the run of + tokens written together (tree-sitter splits `don't` into three, and + `go. on` is a property expression, flattened first). `--`, `__`, `_` + alone, `~` and `_` in a word, trailing durations as in LilyPond's lexer. + Commands are tokens: `\skip`, `\repeat unfold`, `\set stanza`, + `\set`/`\unset ignoreMelismata`. A lone `-` is a word (LilyPond sings it). +- **Targets.** `voice_part_map` (name → part) gave a voice's lyrics to every + voice of its staff. Notes now carry a transient `Note::lyric_voice` tag + (never serialized, cleared before the score is returned) stamped by the + walk: a staff's own voice, each `\new Voice` (named voices keep theirs + when re-entered), each `\\` branch (new voices, as LilyPond makes them), + a variable's own music (`CAPTURE_VOICE`, retagged to the voice that uses + it). A Voice's tag persists after its body in sequential music (LilyPond + keeps the Voice) and is restored per `<< >>` branch. `\addlyrics` follows + the tag of the last note written. The tags made `part_alias`, + `current_uid` and `resolve_uid` unused; they are gone. +- **Attach** after `resolve_ties`, per job in source order (verses numbered + per voice): `assign` applies LilyPond's melisma rules (slur open before + the note, tie continued from the note before, manual beam under + `\autoBeamOff`, `\melisma`), unless `ignoreMelismata`. Three placements: + the tagged voice's notes in time order (`sing`); a NullVoice's own notes + (kept aside, not music) mapped to the score's notes starting with them, + same pitch preferred (`sing_shadow`); a line without `\lyricsto` by its + durations (`sing_timed`). +- **LilyPond writer.** One token per note the lyrics' voice sings, `_` + where a verse has none, under `\set ignoreMelismata = ##t`. The lyrics' + voice is the one with most syllables; `bar_voices` puts it first in each + bar and the music writes multi-voice bars as `<< { \voiceOne … } \new + Voice { \voiceTwo … } >> \oneVoice` for parts with lyrics (a `\\` + passage is invisible to `\lyricsto`, and a re-entered named voice is + followed in its first bar only, both checked with LilyPond). Other singing + voices get a NullVoice (their rhythm, absolute pitches, spacers where + absent). The Music-path writer got the same (`sung`, `has_lyrics`, the + continuing-voice form in `emit_simultaneous`). New oracle measure: + LilyPond sings 710 of 710 syllables of lytk's LilyPond where the IR has + them (two fixes on the way: digits quoted in lyric words; undefined + dynamics via `make-dynamic-script`, which also raised the note oracle to + 11,669 / 11,650 / 9,935). +- **MusicXML**: elided texts joined with `‿`, syllabic from the first's + start and the last's end, written back as ``/`` pairs; + `number_verses` gives each (number, name, repetition on its note) line + its own verse (suite 61g: "there is no correct way"); extend type stop; + chord-note lyrics moved to the first note. +- **ABC**: every verse a `w:` line under every music line (readable as + ABC 2.1 says, line above, and as abc2midi does, one stream); the reader + now aligns a `w:` line from the start of the music line above (unless a + `\` joined it), carries an open word between lines, and unescapes `\X` + (and `\%` in fields). `‿` is kept as written, a space as `~`. +- **MIDI**: continuation marked in the staff's lyric list (`CONTINUES`) + and read as Middle/End; one verse per track (the part's first). +- **Humdrum**: `**text`/`**silbe` read onto the kern spine on their left + (verse per text spine); written per verse right of their kern spine + (`row_with` builds every row kind). + +Boards: lyrics XML→LY 8 → 33 of 38, XML→KRN 0 → 38, XML→MIDI 17 → 33, +XML→ABC 26 → 31, LY→LY 0 → 1. The MIDI and kern boards compare without +extenders, which those formats can't write. What is left: sources whose +syllabic marks contradict (33i, 61g: `end` or `begin` alone), the second +verse's hyphens in two real files, and LY→LY on `example.ly`, whose +syllables are all kept but whose music the Music-path writer re-bars from +bar 7 (one of the six LY→LY onset failures: a writer problem, not a lyric +one). Python: `Note.lyric_syllables`; +the review repros are all plain tests now (M9's two remain). Next: M9. + +## 2026-10-02 — Epic M: M8 (writers) + +- **Keys.** `KeySignature::tonic()` (step and alteration from the circle of + fifths and the mode's offset) replaces two tables: `key_to_ly` had a + major-tonic table for every mode but minor, `key_to_abc` its own walk. +- **LilyPond marks.** The bar's directions are now `Marks { before, after }` + by position: commands (`\tempo`, `\mark`, D.C., breaks, mid-bar + `\clef`) go before the note at their place, post-events after it. + Score-wide commands are written in a part's first staff only (the piano + tempo was in both). `rehearsal_to_ly` writes `\mark \default` only + when LilyPond's next default mark (`RehearsalMark::lilypond_default`, + moved from the reader) prints the mark's text. +- **Music-path directions** were lines after the previous note, so + LilyPond gave them to that note; `emit_direction` writes commands as + lines and the rest as `<>` post-events. The reader turned `<>` into an + empty one-beat chord; it now makes its dynamics, hairpins and texts + directions at the current position, taking no time. +- **Mid-bar clefs.** The timeline split put every clef event in its bar's + attributes. A clef inside the bar is now a `Direction { clef }` at its + offset: split, MusicXML reader (`` after the cursor moved), + lift (`Music::Clef` woven at the place), MusicXML writer + (`direction_element` writes it as an ``; a lone clef on staff + 2 now says `number="2"`), LilyPond writer, kern writer (an + interpretation row before the data at that onset) and the stem engine + (each element's middle line from the clef in force where it starts). + The notation signature scores clefs at their place. +- **D.C.** The MusicXML reader kept `D.C. al Fine` as text + and `` as `da_capo = "D.C."`; the words are now the jump's + text, once. +- **ABC**: `wedge_sign` tracks the open hairpin; the opening clef goes on + `K:`/`V:` (`opening_clef`), later ones inline. An octave clef is written + without `-8`: ABC's player transposes under one, and the notes are + written as they sound (writing `treble-8` made the oracle misread 1,450 + notes). Fingerings and bowings both ways. +- **Kern**: absent voices rest (`ryy`) to the bar's longest voice; + `*part`/`*staff` rows; articulations and fermatas both ways. +- **MIDI**: `assign_channels` honours a source channel only for the first + program to claim it; title (sequence name), composer (a `composer:` text, + as MIDI has no event for it) and copyright written and read. +- **MusicXML placement**: articulations, ornaments and slurs. + +Boards: notation clefs XML→XML 133 → 136, XML→ABC 0 → 108, LY→XML 16 → 17; +dynamics XML→LY 7 → 8, LY→LY 1 → 2. Fidelity XML→KRN 146/144/143 → +149/147/149, bars 141 → 145. LilyPond oracle: 148 compile; LilyPond's +playback agrees with lytk's velocities on 9,913 of 11,705 notes (was +7,642): dynamics now sit where they are. Ten Python repros +(`TestWriters`). Next: M3. + +## 2026-10-02 — Epic M: M4 (stems and beams) + +- **Lifecycle.** The LilyPond and MIDI readers ran + `post_process_beams_and_stems`, so the IR couldn't tell a source's + stems and beams from inferred ones, and the LilyPond writer froze the + inferred ones into its output. The readers now keep only the source's. + `beams::engrave` fills what's left when a writer needs it: MusicXML (in + `build`), ABC (the new `FromIrAdapter` impl, used by `to_abc`) and kern + (`convert`) engrave a copy. The LilyPond writer compares each note's + source stem with `beams::default_stems(part)` (stems cleared, then + engraved) and writes `\stemUp`/`\stemDown` only where they differ, + returning with `\stemNeutral` (or the `\\` voice's own direction). + The main voice's stem state lives in `EmitState` now: it was reset every + bar, so a `\stemDown` lasted past a bar whose notes had no stem. +- **Engine** (`ir/beams.rs`, rewritten; LilyPond's rules, read from + `scm/time-signature-settings.scm`, `lily/auto-beam-engraver.cc`, + `lily/beam.cc`, `scm/music-functions.scm`, not copied): + - Beam ends come from `beamExceptions` (the nearest one at or above the + beam's shortest note) or the beat structure (`Beaming::of`: the table + plus the defaults, 3-groups for numerators divisible by 3, additive + terms). + - Runs are broken by rests, gaps, quarters, notes with source beams and + notes whose beaming the source decided; interspersed grace notes are + passed over, and grace groups beamed apart. + - A run is cut where the beam's ending point falls, recomputed as a + shorter note arrives (LilyPond's recheck: `c8 d e f16 g` is `[c d] + [e f g]`). + - Secondary beams run unbroken (no subdivision by default) and lone + notes get hooks. + - Stems: diatonic staff positions; chord direction from the outermost + heads; beam group from the farthest head, then the majority; voices on + a staff alternate up/down; graces up. +- **Decided beaming.** `Note::no_auto_beam` meant `\autoBeamOff`; it now + means "the source decided" and is set by: + - the MusicXML reader (every note, when the file beams anything — + MuseScore's `hasBeamingInfo`); + - the ABC reader (spacing); + - the kern reader (when the file has `L`/`J`); + - LilyPond `\noBeam` and `\set autoBeaming`. + + The Music tree carries it as `Annotation::NoAutoBeam`, with the source's + beams as `BeamStart`/`BeamStop` (the lift dropped them). The Music-path + LilyPond writer writes `\autoBeamOff`/`\autoBeamOn` from it. +- **ABC.** The reader beams notes written together (`beam_by_spacing`); + the writer glues tokens inside a beam (`GLUE` marker, dropped with the + space at the join). **Kern.** `L J K k` written per level; read with a + per-spine depth (the outer `L` begins, the `J` that closes the last level + ends). **Retrograde** swaps begin/end and hooks. + +New board: stems and beams of MusicXML fixtures stripped, engraved by the +writer and read back, against the source's engraver: stems 980‰, beams +941‰ over 11,874 notes (engravers differ: MuseScore pairs 3/4 eighths, +LilyPond joins all six). ABC-standard writer board 151 → 154 notes, misread +notes 159 → 57 (beamed tokens read as the standard reads them). All 148 +fixtures compile in LilyPond. Release-build writing of the 18-part Rossini: +MusicXML 46 ms (0.4.0: 55), LilyPond 15, ABC 25, kern 19. Deferred: typed +stem/beam enums (P5+), kern `/` `\\` stems, user `beamExceptions`/ +`beatStructure` settings. Next: M8. + +## 2026-10-02 — Epic M: M7 (chord symbols) + +- **One chord model.** `kind_from_suffix` mapped anything it didn't know + to major. `ir/harmony.rs` now reads a chord as its steps + (`ChordSteps`, step → alteration): `ly_chord_steps` follows LilyPond's + chord-mode rules (the first number is the extent of stacked thirds with + a minor seventh; `.N±` adds or alters; `^N` removes; `maj` raises the + seventh, `m7+` is minor-major) and `lead_sheet_chord_steps` reads + lead-sheet suffixes. `kind_and_degrees` picks the MusicXML kind needing + the fewest degree changes, preferring additions to removals. + `ly_chord_modifiers` and `lead_sheet_suffix` write them back; additions + to a triad need its extent (`c:3.5.9`, as `c:9` is a ninth chord and + `c:.9` is a syntax error, checked with LilyPond 2.22). +- **LilyPond chord-mode writer.** `emit_harmony_variable` wrote + `{root}{kind}{bass}{dur}` (the duration after the modifiers) and spread + a bar's chords evenly over the meter's length. `chord_line` places each + chord at its offset, lasting until the next or the bar's end + (`Measure::content_length`, else the meter), with a spacer before the + first; `harmony_to_ly` writes `root dur :modifiers /bass` in the output + language, a rest for N.C. Durations use `length_to_ly` (renamed from + `partial_to_ly`): `2.`, else `4*5`. The reader learned what the writer + can now write: rests (N.C.), scale factors, and a `/` before a note name + only is a bass (`c1*3/4` is a length). +- **The Music path dropped chords.** `music_emit` ignored + `Music::Harmony`, so `lytk convert a.ly -o b.ly` lost every chord + symbol. `chord_symbols` collects them with their printed position + (repeats once, as `written_length`), and the document is written as + `<< \new ChordNames \chordmode { … } music >>` using the same + `chord_line`. A chord-mode block used as notes (`\context Voice + \chords`) read its bass as a second note; the walk skips the rest of a + chord name after `:` or `/` while `quiet` (only ever raised for that). +- **Stacked harmonies.** MusicXML suite 71g: "multiple subsequent harmony + elements, indicating a harmony change during a note". Harmonies at the + cursor with no `` before a note are spread evenly over it; the + writer gives them ``s, so they round-trip. MuseScore 4.6 writes + stacked chords at a bar's start too (2340), where neither reading finds + the intended beat. The Music tree can't put a chord mid-note, so `weave` + puts it on the note it sounds over rather than on the next one (which + could be in the next bar). +- **Smaller fixes on the way.** The MusicXML kind tables were two + hand-written lists that disagreed (functional kinds became `other`): + both use the crate's own (de)serializer. ABC chord roots keep double + accidentals. The ABC writer's `has_audible` now looks inside voices + sounding together (an M8 item: a staff whose bars all had two voices was + dropped). + +Boards: the notation signature compares chords by their steps (`m7 add 9` +equals `m9`). XML→XML chords 7 → 8, XML→LY 0 → 7, LY→LY 0 → 1, XML→ABC +dynamics 8 → 9; XML→ABC chords 5 → 4 — three files matched only because +every chord sat on beat 1 on both sides, and ABC can't write a change +during a note (a tied split would add notes to every Music-tree writer's +output; not done). Fidelity XML→ABC 149/149/148 → 152/152/151, bars 147 → +148. LilyPond compiles all 148 fixtures. Tests: `ly_to_ir::tests::m7`, +`m7_notes`, `test_emit_harmony_at_its_beat`, +`chord_symbols_stacked_before_a_note_change_during_it`, three Python +repros (two flipped from xfail). Next: M4. + +## 2026-10-02 — Epic M: M6 (Music tree and representations) + +- **Velocity per part.** The note-array walk shared one running velocity + across simultaneous branches, so a part without dynamics took the last + dynamic of the part before it. The walk now carries a `Perf` (velocity, + transposition) copied into each branch of a `Simultaneous`; the first + branch, the voice that goes on, carries its state on after the block. +- **Direction dynamics.** The walk's catch-all ignored `Music::Direction`: + MusicXML dynamics (directions, not note marks) left every velocity at 64. + The lift already weaves directions into the first voice at their + offsets, so applying them was enough. +- **One velocity table.** Note arrays used the common ladder (p 49, mf 80, + 64 by default) and MIDI export LilyPond's (p 69, mf 86, 90): the same + score gave different velocities in a note array and in its MIDI. Note + arrays now use LilyPond's (`lilypond_velocity`, the export's own + truncation; instruments' equalizers excepted), default 90. + `dynamic_to_velocity` had no other user and is gone. The 32-bin event + encoding is banded around 90 (88): its round-trip tests compare velocity + within a bin. +- **Grace chords** take no time in the lift (`Music::Grace` around them, + as for single grace notes); they pushed every later note back. +- **Transposition.** The Music tree had no notion of it, so every Music-tree + consumer lost transposing instruments. `Music::Transposition(Transpose)` + is lifted from a measure's attributes, lowered to the timeline's + `Event::Transpose`, written as `\transposition` by the Music-path + LilyPond writer, applied by the note-array walk (sounding pitch by + default; `pitch="written"` in Python opts out). Since the boards compare + note arrays, ABC and kern had to carry it: ABC 2.1's `transpose=` (read + from `K:`/`V:`, written inline as `[K:transpose=N]`; the test oracle + plays it too) and kern's `*ITrd…c…` (read and written). + `Transpose::from_semitones` spells a semitone count as the usual interval. +- **Kern interpretations.** The writer wrote clef, key, meter (and now + transposition) for the first bar only; `interpretation_rows` writes what + any bar sets, so meter and key changes survive. + +Boards: XML→KRN 139/137/143 → 146/144/143, bars 134 → 141 (the later +meters); XML→ABC bars 146 → 147; the ABC-standard writer board 149 → 151 +pitches and notes, misread notes 210 → 159; notation board kern clefs +108 → 110. Diagnostics now survive `to_music_document()`/`to_score()`. +Tests: `transposing_instruments_sound_the_same_through_abc_and_kern`, +`transposition_comes_from_semitones_as_the_usual_interval`, and the four +M6 Python repros. Next: M7. + +## 2026-10-02 — Epic M: M5 (LilyPond constructs read wrong) + +Each construct got a failing test first (`ly_to_ir::tests::m5`). + +- `\afterGrace [FRACTION] MAIN { GRACES }`: the handler took the next item + (MAIN) for the grace and left the block to be read as timed music. MAIN + now goes through the normal path (`walk_one`: a note, a chord or a + block) and the block after it becomes after-graces. +- Grace groups: `parse_grace_block` returns voice elements, each with its + attachments (beams and slurs inside a grace group were dropped), chords + as chords (they were flattened into one grace after another), nested + blocks included; `grace_one` reads an unbraced note or chord + (`\grace 8` was a timed chord). Chord handling moved out of the + block loop into `handle_chord`, which both share. +- `\repeat tremolo`: read as a volta repeat until now, for both forms. + One note or chord is a tremolo `N` times its value (`\repeat tremolo 8 + c32` is `c4` with 3 strokes, the `c4:32` representation), two alternate, + each `N` times its value, with `two_note_tremolo`/`tremolo_start`, the + representation the writers already use. A longer body is played out with + an `unsupported-value` warning. The body is walked normally and the + elements it pushed are rewritten in the voice buffer. +- `\partial 8*3` takes its multiplier; `\time 3,2 5/8` skips its beat + structure (the signature is the fraction; beaming is M4's). +- Placement: `consume_attachments` emits a `^`/`_` marker before the mark + it places, and `apply_note_attachments` applies it to the next mark only + (articulations, ornaments, dynamics, wedges, slur starts, fermatas, which + are inverted below). A string after a direction is a text script + (`c^"dolce"` fell out of the attachments and was skipped). `\slurUp`/ + `\slurDown` set where an unplaced slur goes. `-_` maps to portato. +- `\once \stemUp` and its kind apply to the next note only (`once_stem`). +- Marks: `\mark "Intro"` and `\mark \markup {…}` are rehearsal marks + (they were "ignored for now"), `\mark \default`/`\mark 3` follow + LilyPond's lettering (no I), and LilyPond 2.24's `\textMark`, + `\textEndMark`, `\jump`, `\fine` (text above), `\sectionLabel` (a + rehearsal mark), `\segnoMark`, `\codaMark` are read. + +Graces and slurs, both ways: the writers wrote every grace note as +`\acciaccatura` (slashed) or `\appoggiatura`, each of which adds a slur, so +MusicXML graces gained slurs. The reader now reads `\acciaccatura` and +`\appoggiatura` as graces plus an explicit slur to the main note +(`grace_slur_to_main`), and the writers write `\slashedGrace`/`\grace` with +the slurs the IR has. The LilyPond writer also writes placement now +(`placed` in `ir_to_ly/emit.rs`). + +The LilyPond oracle is unchanged (148 fixtures compile, the same onsets +and pitches); the boards don't move (their fixtures hardly use these +constructs). Next: M6. + +## 2026-10-02 — Epic M: M1 (MusicXML positions) and M2 (MusicXML validity) + +**M1.** `mxml_to_ir/part.rs` kept ``/`` for direction +offsets only and appended every note to its voice's list; a voice-less +`` became a spacer in voice 1. Now a `Frac` cursor (whole notes, +so a mid-bar divisions change is harmless) places each note, rest and +chord at its onset; ``/`` move it. A `` with a +voice is that voice's hidden rest, and so is one without a voice that +starts where the last note's voice ended; any other is only a cursor move +(MuseScore's `` before a voice entering mid-bar). The +voices are then laid out by onset with `Timeline::place_voice`, the +LilyPond reader's lanes: gaps become spacers, music overlapping its own +voice moves to a free voice. Chord symbols and figures take the cursor +(plus ``) in the IR's 4-per-quarter offsets; a second +`` in a bar is merged into the first instead of replacing it +(the bar's key and time were lost at a mid-bar clef change). + +Positions now come from ``, as the MusicXML spec and other +readers have it, where they came from `` before. That surfaced two +writer bugs: `compute_score_divisions` ignored dots, so dotted 128ths were +truncated (now: the lcm of every element's length in quarters, and a +multiple of the offset unit), and `voice_duration` counted grace notes, so +the `` after a voice with grace notes went past the bar's start. +A `` of 0 falls back to the written value. The suite's 33e, +whose first note is a quarter with a whole note's ``, now reads +with the gap the file states (it stays in the overfull count). + +Results: the suite's 46e reads as described; the overfull-voice count fell +from 7 to 4 (11d–11f are composite meters lytk can't hold; 33e above); +XML→ABC 144/144/145 → 149/149/148 (bars 142 → 146), XML→KRN 138/136/142 → +139/137/143, the notation board's clefs up in four directions. + +Kern had to follow: its writer wrote a spacer as null tokens, which in +kern means "the note before goes on", so a voice starting on beat 2 was +read back on beat 1. Spacers are invisible rests (`4ryy`) now, read back as +spacers, and a pickup is found on the longest spine rather than the +leftmost (a voice ending early made the whole piece look like it had a +1/4 pickup). The writer also takes each spine's clef from its own staff +(it took a `HashMap`'s first entry, which made a guard test flaky). + +**M2.** The MusicXML writer emitted `` only for bars with +attribute changes, so a score without explicit time, key or clef (14 of +35 LilyPond fixtures, every Humdrum score) had no ``; a part's +first bar now always has them. The `musicxml` crate writes text and +attribute values raw and reads them raw: lytk now renders the crate's +element tree itself (`musicxml_internal`, already a dependency of the +crate, is now a direct one) with every value escaped, and declares the +4.0 DTD. On reading, a pre-pass decodes entities in text and attribute +values and keeps line breaks inside text (the crate deletes them); `<`, `"`, +newline and tab go through Unicode noncharacters (U+FDD0…), which never +occur in real text, unlike the private-use area SMuFL glyphs use, and are +put back in the score's strings afterwards (a JSON walk, only when any was +used). One `decode_text_bytes` reads UTF-8, UTF-16 by its byte-order mark, +or else Latin-1, for MusicXML, ABC, Humdrum and MIDI text. A part that +times notes before declaring divisions (lytk 0.4.0's output) gets the +divisions its first note implies. MuseScore 4 writes its accidental glyph +names into part names (`BaccidentalFlat Trumpet`); they read as `♭`. + +A new note board, LY → XML → IR, measures what the missing `` +broke: 35/35 on notes, pitches, onsets and bars. + +The review's chord-symbol evidence was wrong in one place: the 2340 lead +sheet really writes both chords of its two-chord bars before the first +note, so both are on beat 1 there; the reader's cursor bug was real +(`chord_symbols_and_figures_sit_at_the_cursor`) and the Python repro now +uses a bar of its own. + +Tests: `mxml_to_ir::tests` (cursor, forwards, chord symbols, merged +attributes, inferred divisions, divisions always written), `text_tests` +(encodings, entities, escaping round trip). Next: M5, then M6, M7, M4. + +## 2026-10-02 — Full review; Epic M (0.5.0) planned, M0 done + +A review of the whole library (2026-10-01): the IR, every reader and writer, +the transforms, the bindings, datasets and CLI, with probes against the 0.4.0 +build and a feature-survival matrix (11 real MusicXML files through XML, LY, +ABC, KRN and MIDI, about 30 notation features counted). Notes, pitches, +onsets and bar lengths are well guarded; nearly everything the boards don't +measure is broken somewhere, and the boards can't see it because both sides +of every round trip use lytk's own reader. + +What was confirmed (file and line numbers in the plan, summarized here): +- **Lyrics.** The LilyPond reader ignores slur and tie melismas (its slur + rule only applies under `\autoBeamOff`, and lyrics attach before ties are + resolved), reads `__` as two skips, splits `a~b` and `don't`, numbers every + verse 1, keeps only the last `\lyricsto` stanza of a voice and targets a + part instead of a voice. The writers write `_` only into verses already + started and never control LilyPond's melismas; MusicXML drops elided text + and non-numeric verse numbers; ABC drops leading skips; Humdrum ignores + `**text`; the MIDI reader's syllabic ignores the previous hyphen. + XML→LY keeps the lyrics of 8 of 38 fixtures. +- **Stems and beams.** The clef's middle line is computed in semitones + (treble: A4), each note decides alone (a beam mixes up and down), voices + are ignored; beams cross rests and quarter notes, 3/8, 5/8 and additive + meters are never beamed. The readers store these guesses, and the LilyPond + writer writes them back as `\stemDown`/`[ ]`, also inside `\\` voices. + ABC never beams; kern and the lift drop beams and stems. +- **MusicXML reading** places notes in file order: ``/`` + only move direction offsets, a voice-less `` becomes a spacer in + voice 1. The suite's 46e ("voice 2 starts on beat 2") fails; 4 of 11 real + files get overfull voices. Chord symbols ignore the cursor (all on beat 1). +- **MusicXML writing**: text is not escaped (a `&` in a title makes XML + nothing can read), and `` is missing when the source has no + explicit time, key or clef (14 of 35 LilyPond fixtures). +- **LilyPond** chord names are written `a:71` for `a1:7`; church modes get + the major tonic (`\key c \dorian` for D dorian); text scripts + (`^"dolce"`), `\partial 8*3`, `\time 3,2 5/8`, `\afterGrace`, grace chords + and single-note `\repeat tremolo` are misread without a diagnostic. +- **Representations**: velocity leaks from one part into the next, MusicXML + direction dynamics are ignored, transposing instruments are at written + pitch, grace chords take time. +- **Other writers and transforms**: ABC drops a staff whose bars are all + multi-voice and closes diminuendos as crescendos; the Humdrum writer makes + a 2-staff piano read back as 5 parts and 1,595 bars; retrograde leaves key + signatures in place and doesn't flip beams; `transpose(s, 6)` from G picks + C♯ major; MIDI keeps source channels shared by different programs; the CLI + writes only the first movement for ly→ly and transforms. +- **API**: no `load`/`save`, no `PathLike`, scores immutable and not + picklable, navigation re-copies on every access, the GIL is held by most + bindings, decoders demand exact dtypes. These and the ML/analysis features + (tokenizers, augmentation, downloadable corpora, Humdrum spine splits, + MuseScore files) are planned after 0.5.0. +- Not confirmed: the Humdrum writer does write each staff's clef (the + piano fixture's lower staff starts in treble); a slurred pair round-trips + through LilyPond today only because the reader and the writer share the + same wrong melisma rule (now a guard test for M3). + +The owner chose to fix the silent corruption first: Epic M is 0.5.0 (table +in the roadmap) and P5 moves to 0.6.0. + +**M0, measurement.** `common::notation_signature` lists a score's lyric +syllables, chord symbols, dynamics (a note's or a measure direction's +alike) and clefs, each at its onset, keyed by no part (a conversion may +split a piano). `tests/notation_fidelity.rs` gates seven directions on it, +per family, with today's numbers as baselines, and gates the count of +MusicXML fixtures read with a voice longer than its bar (7: three are the +composite time signatures lytk can't hold yet, 11d–11f). Baselines +(lyrics, chords, dynamics, clefs): XML→XML 38/7/13/129, XML→LY 8/0/7/125, +XML→ABC 26/5/7/0, XML→KRN 0/0/0/105, XML→MIDI 17 (first verse), LY→LY +0/0/1/16, LY→XML 2/1/2/16. `lilypond_oracle.rs` gains a lyric oracle: 26 +small LilyPond files, one per rule, compiled by `lilypond`; its MIDI lyric +events (a Lyrics context is a track of its own) must sit where lytk puts +each syllable. 11 of 26 align today; LilyPond writes a lyric tie as `~`, +and a `_` skip as an empty event (ignored). `tests/test_review_regressions.py` +holds one test per confirmed bug (42), each a strict xfail naming its task. + +Next: M1 (MusicXML positions) and M2 (MusicXML validity). + ## 2026-09-28 — Release 0.4.0 Epics K and L ship as **0.4.0**, with the Scheme tokens and diff --git a/docs/import-export.md b/docs/import-export.md index 05c7861..5ccec84 100644 --- a/docs/import-export.md +++ b/docs/import-export.md @@ -15,8 +15,8 @@ Layer 1 (`MusicDocument`, the Music tree the ML representations consume). | MusicXML | `.xml` `.musicxml` | ✅ | ✅ | The most complete adapter | | Compressed MusicXML | `.mxl` | ✅ | ✅ | ZIP handled natively | | MIDI | `.mid` `.midi` | ✅ | ✅ | Export plays like LilyPond's MIDI; import is being rebuilt (see below) | -| ABC | `.abc` | ✅ | ✅ | ABC 2.1 pitches and rhythm; repeats, lyrics, decorations in progress | -| Humdrum (`**kern`) | `.krn` | ✅ | ✅ | Core `**kern`; spine rearrangement (`*^`/`*v`) unsupported | +| ABC | `.abc` | ✅ | ✅ | ABC 2.1, checked against an independent ABC 2.1 player; overlays across bars not read | +| Humdrum (`**kern`) | `.krn` | ✅ | ✅ | `**kern` and `**text`; spine rearrangement (`*^`/`*v`) unsupported | | MEI | `.mei` | 🔲 | 🔲 | Planned | **Python:** `from_lilypond` · `from_musicxml` · `from_midi` · `from_abc` · @@ -25,6 +25,26 @@ Layer 1 (`MusicDocument`, the Music tree the ML representations consume). `to_event_sequence`, `compute_metrics`) operate on a `MusicDocument` (`score.to_music_document()`). +## What survives a conversion, measured + +The tables below say what each adapter reads and writes. Whether it survives +is measured by test boards over the fixtures (152 MusicXML files, mostly the +MusicXML test suite; 35 LilyPond files), and none may fall +(`tests/fidelity.rs`, `tests/notation_fidelity.rs`, `tests/lilypond_oracle.rs`). +Numbers as of 0.5.0. + +| Board | What is compared | Result | +|---|---|---| +| Notes, MusicXML → X → IR | onset, duration and sounding pitch of every note; bar lengths | XML 152/152, ABC 151/152, kern 149/152 | +| Lyrics | every syllable: verse, onset, text, syllabic, extender | through MusicXML 38/38, kern 38/38*, MIDI 33/38* (first verse), LilyPond 33/38, ABC 31/38 | +| Chord symbols | onset, root, chord (as steps: `m7 add 9` = `m9`) | MusicXML 8/8, LilyPond 7/8, ABC 4/8 (ABC can't change chord during a note) | +| Clefs | onset, staff, clef (mid-bar changes at their place) | MusicXML 136/137, LilyPond 131/137, kern 110/137, ABC 108/137 | +| Stems, beams | MusicXML sources' stems and beams, stripped and drawn again by lytk (LilyPond's rules), against the source's engraver | stems 98.0 %, beams 94.1 % of 11,874 notes | +| LilyPond lyrics | LilyPond 2.22's own MIDI says which note sings what: lytk reading LilyPond, and LilyPond singing lytk's LilyPond | 31/31 cases; 710/710 syllables | +| LilyPond playback | LilyPond plays lytk's LilyPond of every fixture against lytk's MIDI | 148/148 compile; 11,669/11,705 onsets and pitches | + +\* without extender lines, which `**text` and MIDI can't write. + --- ## MusicXML @@ -40,7 +60,7 @@ Layer 1 (`MusicDocument`, the Music tree the ML representations consume). | Tuplets / time-modification | ✅ | Actual/normal notes, tuplet display | | Key signatures | ✅ | Fifths + mode (Major, Minor, church modes) | | Time signatures | ✅ | Beats, beat-type, compound, symbol | -| Clef changes | ✅ | G, F, C, Percussion, Tab with octave-change | +| Clef changes | ✅ | G, F, C, Percussion, Tab with octave-change; a change inside the bar at its place | | Transpose | ✅ | Diatonic, chromatic, octave-change | | Articulations | ✅ | staccato, tenuto, accent, marcato, etc. | | Ornaments | ✅ | trill, mordent, turn, tremolo, wavy-line | @@ -51,7 +71,7 @@ Layer 1 (`MusicDocument`, the Music tree the ML representations consume). | Beams | ✅ | Begin, continue, end, hooks | | Grace notes | ✅ | Regular, slash (acciaccatura), steal-time | | Cue notes | ✅ | `` element | -| Lyrics | ✅ | Syllabic, text, extend, elision | +| Lyrics | ✅ | Syllabic, text, extend (and its stop), elided words (`my‿a`), verse number and name (a line per number, name and repetition) | | Tempo (metronome + sound) | ✅ | Beat-unit, BPM, text label | | Text directions (words) | ✅ | With font-style, font-weight | | Rehearsal marks | ✅ | Text content | @@ -65,7 +85,7 @@ Layer 1 (`MusicDocument`, the Music tree the ML representations consume). | Multi-voice | ✅ | Voice number mapping | | Multi-staff | ✅ | Staff number, `` | | Part groups | ✅ | Group-name, group-symbol, number | -| Harmony / chord symbols | ✅ | Root, kind, bass, degrees | +| Harmony / chord symbols | ✅ | Root, kind, bass, degrees, at the cursor plus ``; harmonies one after another before a note change during it (suite 71g) | | Figured bass | ✅ | Figures, parentheses | | Page layout / defaults | ✅ | Page dimensions, margins, staff size | | Glissando | ✅ | Start/stop, line-type | @@ -194,14 +214,14 @@ Layer 1 (`MusicDocument`, the Music tree the ML representations consume). | `\language` | ✅ | At the top level, in `\score` and in music; an unknown name keeps the language in force | | `\set Staff.instrumentName` | ✅ | Part name from `\set` property | | `\set Staff.midiInstrument` | ✅ | MIDI instrument from `\set` property | -| `\context Voice = "name"` | ✅ | Named voices for lyrics attachment | -| Lyrics | ✅ | `\lyricsto`, `\lyricmode`, `\lyrics`, `\context Lyrics`, `\addlyrics` | +| `\context Voice = "name"` | ✅ | Named voices: `\lyricsto` follows the voice, not its staff | +| Lyrics | ✅ | Sung where LilyPond sings them (31 cases checked against LilyPond's own MIDI): melismas from slurs, ties, manual beams under `\autoBeamOff` and `\melisma`; `--`, `__`, `_`, `~` and `_` in words, punctuation; `\skip`, `\repeat unfold`, `\set stanza`, `ignoreMelismata`; verses per voice in source order; `\lyricsto`, `\addlyrics`, `\lyricmode`, NullVoice, and a Lyrics line without `\lyricsto` (by its durations) | | Staff variables | ✅ | `staffX = \new Staff { ... }` with full part metadata | | `\cadenzaOn/Off` | ✅ | Free-time span collapses to one `senza_misura` measure (both hands, score-wide) | -| `\melisma/End` | ✅ | Gracefully skipped | -| `\autoBeamOff/On` | ✅ | Gracefully skipped | +| `\melisma/End` | ✅ | A melisma for the lyrics | +| Stems, beams | ✅ | Only what the source says (`\stemUp`, `\voiceOne`, `[ ]`, `\once`); `\autoBeamOff`, `\noBeam`, `autoBeaming` mark the beaming as decided. Writers that spell stems and beams out draw the rest (LilyPond's rules) | | `\dynamicUp/Down` | ✅ | Gracefully skipped | -| Harmony/chord names | ✅ | `\chordmode`, `\chords` (root, quality, bass; language-aware) | +| Harmony/chord names | ✅ | `\chordmode`, `\chords`: root, any modifiers (`9^7`, `m7+`, `3.5.9`, `13.11`) as kind and degrees, bass, a rest as N.C., scaled lengths; language-aware | | Figured bass | ✅ | `\figuremode`, `\figures` (figures, accidentals incl. natural & double) | ### Diagnostics @@ -251,11 +271,11 @@ Scheme parenthesis, 499 have one. | Clef changes | ✅ | All types with octave shifts | | Articulations | ✅ | All standard LilyPond articulations | | Ornaments | ✅ | `\trill \mordent \prall \turn` etc. | -| Dynamics | ✅ | Note-attached | +| Dynamics | ✅ | Note-attached; one LilyPond doesn't define as `make-dynamic-script` | | Wedges (hairpins) | ✅ | `\< \> \!` | | Slurs | ✅ | `( )` | | Ties | ✅ | `~` | -| Grace notes | ✅ | `\acciaccatura \appoggiatura \afterGrace` | +| Grace notes | ✅ | `\grace`, `\slashedGrace`, `\afterGrace`, with the source's slurs | | Tempo | ✅ | `\tempo` with text/BPM | | Text directions | ✅ | `\markup` | | Rehearsal marks | ✅ | `\mark` | @@ -264,7 +284,8 @@ Scheme parenthesis, 499 have one. | Barlines | ✅ | `\bar` types | | Repeats | ✅ | `\repeat volta` | | Volta endings | ✅ | `\alternative` | -| Multi-voice | ✅ | `<< \\\\ >>` | +| Multi-voice | ✅ | `<< \\\\ >>`; in a part with lyrics, `<< { \voiceOne … } \new Voice { \voiceTwo … } >>` so the lyrics' voice goes on | +| Stems, beams | ✅ | A stem command only where the source's stem isn't LilyPond's own; beams only where the source has them (`\autoBeamOff` where it decided) | | Multi-staff | ✅ | Staff groups, PianoStaff | | Part names | ✅ | `instrumentName` | | Glissando | ✅ | With style override | @@ -273,14 +294,14 @@ Scheme parenthesis, 499 have one. | Anacrusis | ✅ | `\partial` | | Paper block | ✅ | Page dimensions, margins | | Header block | ✅ | Title, composer, etc. | -| Harmony (ChordNames) | ✅ | Separate `\chordmode` variable | +| Harmony (ChordNames) | ✅ | Separate `\chordmode` variable: `root dur :modifiers /bass` at each chord's beat, N.C. as a rest; the Music path writes a ChordNames line | | Figured bass | ✅ | Separate `\figuremode` variable | | MIDI instrument | ✅ | `\set Staff.midiInstrument` | | Score block | ✅ | `\layout { } \midi { }` | | Fermata | ✅ | `\fermata` | | Octave shifts | ✅ | `\ottava` | | Pedal | ✅ | `\sustainOn \sustainOff` | -| Lyrics | ✅ | Score path: `\new Lyrics \lyricsto`; Music path: `\addlyrics` | +| Lyrics | ✅ | A token per note under `ignoreMelismata` (LilyPond sings 710/710 syllables where lytk has them); verses with `\set stanza`; another voice's lyrics on a NullVoice; Score path `\new Lyrics \lyricsto`, Music path `\addlyrics` | --- @@ -314,7 +335,7 @@ straightened. | Program changes | ✅ | → `Part.midi_program` and the GM instrument name | | Dynamics | ✅ | Each note keeps its velocity (`Note.velocity`); a dynamic mark where a part's level changes (LilyPond's table for files LilyPond or lytk wrote) | | Tempo | ✅ | Tempo events at their positions, whole beats a minute where a writer truncated them | -| Pedal, lyrics | ✅ | CC64 → pedal marks (start, stop, change); lyric events → lyrics on the top note starting there | +| Pedal, lyrics | ✅ | CC64 → pedal marks (start, stop, change); lyric events → lyrics on the top note starting there, a word's syllables from their hyphens; title, composer (a `composer:` text) and copyright | | Format 2 | 🔲 | Refused with a clear error | | Played MIDI | ✅ | Onsets placed by a Viterbi search: distance plus a cost for points weaker in the meter than the note's length expects, plain (to 32nds, or `quantize=`) or triplet beats (two onsets off the 16th grid), a rolled chord as one. Beats tracked when the playing drifts from the file's tempo (rubato, a late start). A one-track piano splits into hands by MuseScore's cost model. Swung eighths (3:2 to 2:1) straightened and marked "Swing" (`swing=`; a 3:1 shuffle reads dotted unless asked). A file without a key signature gets one (Krumhansl–Kessler profiles); karaoke text events are lyrics. A performance far from the file's tempo from the start is not re-timed, and played quarter-note triplets read as syncopations | @@ -335,9 +356,9 @@ LilyPond's own MIDI of the test pieces note for note (onsets, pitches, and | Per-note velocity | ✅ | `Note.velocity` (from MusicXML ``; MIDI import sets it in phase C) wins | | Articulations | ✅ | Staccato, staccatissimo, portato shorten; accent, marcato add velocity (`ly/script-init.ly`) | | Unisons | ✅ | Two voices on one key play it once (LilyPond's MIDI walker) | -| Tracks and channels | ✅ | Conductor track + one track per staff; one channel per part; percussion on channel 10; past 15 parts, channels are shared by program | +| Tracks and channels | ✅ | Conductor track + one track per staff; one channel per part, never two programs on one; percussion on channel 10; past 15 parts, channels are shared by program | | Sustain pedal | ✅ | CC64 from pedal directions | -| Lyrics | ✅ | Lyric events (first verse) | +| Lyrics, metadata | ✅ | Lyric events, one verse a track (the part's first); title as the sequence name, composer, copyright | | Transposing instruments | ✅ | `` applied: sounding pitch | | Tempo, key, time | ✅ | Conductor track; tempo changes at their position (120 BPM if none) | | Slurs, ornaments, fermatas | 🔲 | Not performed | @@ -369,10 +390,11 @@ The reader and the writer are checked against an independent ABC 2.1 player | Multi-voice (`V:`) | ✅ | Header/body `V:id`, inline `[V:id]`, `name=`; each voice → a Part | | Overlays (`&`) | 🟡 | Each layer is a voice of its own for that bar; multi-bar `(&`…`&)` not read | | Bar lines + repeats | ✅ | `|`, `||`, `|]`, `|:`, `:|`, `::`, endings `[1`, `|1`, `:|2`. A bar line always ends a bar: a short first bar is the pickup, and any other bar keeps its length | -| Decorations | ✅ | `!p!`…`!ffff!`, `!sfz!`; hairpins `!<(!`/`!<)!`/`!>(!`/`!>)!` (and `!crescendo(!`…); `.` `!>!` `!tenuto!` `!wedge!` `!breath!`; `T` `M` `P` `~` and their `!…!` names; `H`/`!fermata!`. Bowings, segno and coda are skipped | -| Chord symbols, annotations | ✅ | `"Am7"`, `"F#m7b5"`, `"G/B"` → chord symbols; `"^text"`/`"_text"` (and `<`, `>`, `@`) → words | +| Decorations | ✅ | `!p!`…`!ffff!`, `!sfz!`; hairpins `!<(!`/`!<)!`/`!>(!`/`!>)!` (and `!crescendo(!`…); `.` `!>!` `!tenuto!` `!wedge!` `!breath!`; `T` `M` `P` `~` and their `!…!` names; `H`/`!fermata!`; fingerings `!1!`…`!5!`, bowings `u` `v`. Segno and coda are skipped | +| Beams | ✅ | Notes written together are beamed (§4.7) | +| Chord symbols, annotations | ✅ | A quoted string is a chord symbol when it reads as one (`"Am7"`, `"F#m7b5"`, `"G7b9"`, `"Ebbmaj7/Bb"`), `"N.C."` none; otherwise (`"Fine"`) and `"^text"`/`"_text"` (and `<`, `>`, `@`) words | | Slurs, tempo | ✅ | `(`…`)`, nested; `Q:1/4=120`, `Q:"Allegro" 3/8=80`, old `Q:120` | -| Lyrics | ✅ | `w:` under the notes since the last `w:` (`-`, `_`, `*`, `~`, `\-`, `|`); consecutive `w:` lines are verses; `W:` kept as metadata | +| Lyrics | ✅ | `w:` under the music line above (ABC 2.1; a word may go on to the next `w:` line), `-`, `_`, `*`, `~`, `|`, escapes `\-` `\*` …; consecutive `w:` lines are verses; `W:` kept as metadata | | MIDI instrument | 🔲 | ABC has **no standard** instrument field — `%%MIDI program N` is a non-standard `abc2midi` extension and is not parsed | ### Export (IR → ABC) — `src/adapters/ir_to_abc.rs` @@ -387,9 +409,10 @@ The reader and the writer are checked against an independent ABC 2.1 player | Bar lines + repeats | ✅ | Derived from the meter plus explicit bar lines; `|:`/`:|` with `[1`/`[2` endings; a pickup as a short first bar; an irregular bar keeps its length; a note across a bar line is tied over it | | Inner voices | ✅ | A bar's voices as `&` layers (ABC 2.1 §7.4); voices running across bar lines as the richest one | | Spacers | ✅ | `x` | -| Decorations, slurs | ✅ | Dynamics, hairpins, articulations, ornaments, fermatas, slurs; a direction's dynamic or hairpin goes on the next note | +| Decorations, slurs | ✅ | Dynamics, hairpins (each closed as it opened), articulations, ornaments, fermatas, fingerings, bowings, slurs; a direction's dynamic or hairpin goes on the next note | +| Clefs, beams | ✅ | A voice's clef on its `K:`/`V:` line, changes inline; beamed notes written together | | Chord symbols, words, tempo | ✅ | `"Am7"`; `"^dolce"`; `Q:` in the header, `[Q:]` inside | -| Lyrics | ✅ | A `w:` line under each music line, per verse (`*` under a note without a syllable, `_` while one is held) | +| Lyrics | ✅ | Every verse a `w:` line under every music line (`*` under a note without a syllable, `_` while one is held), readable both as ABC 2.1 and as abc2midi align them | | MIDI instrument | 🔲 | **Deliberately not emitted.** ABC has no standard instrument field; `%%MIDI program N` is a non-standard `abc2midi` directive, so instrument identity is dropped on `→ ABC` (a format limit, not a bug). It is kept across LilyPond ↔ MusicXML ↔ MIDI. | --- @@ -413,7 +436,10 @@ part/voice, with `.` padding on the time slices a spine does not sound. | Key / time / clef | ✅ | `*k[…]`, `*M4/4`, `*clefG2` | | Instrument name | ✅ | `*I"…` | | Reference records | ✅ | `!!!OTL`, `!!!COM` → title / composer | -| Fermata | ✅ | `;` | +| Fermata, articulations | ✅ | `;`; `'` `` ` `` `^` `^^` `~` | +| Beams | ✅ | `L` `J` | +| Lyrics | ✅ | `**text`/`**silbe` spines, on the kern spine to their left (a verse each) | +| Transposition | ✅ | `*ITrd…c…`; invisible rests `ryy` | | Spine rearrangement | 🔲 | `*^` / `*v` raise a clear error rather than mis-parsing | ### Export (IR → kern) — `src/adapters/ir_to_humdrum.rs` @@ -423,10 +449,11 @@ part/voice, with `.` padding on the time slices a spine does not sound. | Pitch / duration / rests / chords | ✅ | | | Tuplets | ✅ | Ratio folded into the recip (`12` = triplet eighth) | | Grace notes | ✅ | Each gets its own data record, `.` in the other spines | -| Ties / slurs / fermata | ✅ | | +| Ties / slurs / fermata / articulations / beams | ✅ | Beams drawn by LilyPond's rules where the source leaves them | | Barlines + repeats | 🟡 | `=N`, `==`; repeats as `:|!`, `!|:`; endings not written (kern needs `*>` expansion lists) | -| Key / time / clef / instrument | ✅ | Tandem interpretations | -| Multi-voice | ✅ | One spine per voice | +| Key / time / clef / instrument | ✅ | Tandem interpretations, wherever they change (a clef inside a bar too); `*part`/`*staff` | +| Multi-voice | ✅ | One spine per voice; a voice absent from a bar rests there invisibly | +| Lyrics | ✅ | A `**text` spine per verse | ## MEI (planned) diff --git a/docs/python-api.md b/docs/python-api.md index 8e25888..66e6cdb 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -401,6 +401,13 @@ Tie events: ``"start"`` / ``"stop"`` / ``"continue"``. #### `Note.lyrics: list[str]` +#### `Note.lyric_syllables: list[dict[str, object]]` + +The note's syllables with their verse: dicts of ``verse``, ``name``, +``text``, ``syllabic`` (``"single"``, ``"begin"``, ``"middle"``, +``"end"``), ``extend`` and ``elision`` (words sung on one note, joined +with ``‿``). + ### class `Rest` A rest or spacer (read-only). @@ -555,10 +562,14 @@ as they are), a circular include or an expansion past the bounds. ## ML representations (Epic D) -### `to_note_array(doc: MusicDocument, resolution: int=480) -> npt.NDArray[np.int32]` +### `to_note_array(doc: MusicDocument, resolution: int=480, *, pitch: str='sounding') -> npt.NDArray[np.int32]` Encode a document as a ``(N, 4)`` array: (onset, duration, pitch, velocity). +Pitches sound as played (a B♭ clarinet's written D is a C) unless +``pitch="written"``. Velocities are those ``to_midi`` plays (LilyPond's +dynamics table, 90 without a dynamic). + ### `from_note_array(array: npt.NDArray[np.int32], resolution: int=480) -> MusicDocument` Decode a ``(N, 4)`` note array back into a document. diff --git a/docs/roadmap.md b/docs/roadmap.md index aa50a1d..3041d5d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,7 +2,31 @@ Items are grouped by status. Completed items are kept for reference. -## Latest status (2026-09-28, Epics K and L) +## Latest status (2026-10-02, Epic M done: 0.5.0) + +**Epic M is done and is version 0.5.0** (not tagged yet: the owner tags and +publishes). The 2026-10-01 review found that what the note boards didn't +measure was broken somewhere; M0 measured it, M1–M10 fixed it, and every +measurement now gates CI: lyrics, chord symbols, dynamics and clefs at their +places across seven directions; stems and beams against the source +engravers (98.0 % / 94.1 %); LilyPond lyrics against LilyPond's own MIDI +(31/31 cases read, 710/710 syllables written); LilyPond playback of every +fixture (148 compile). Every confirmed bug of the review is a passing test +(`tests/test_review_regressions.py`). Next: P5+ as 0.6.0 (below). + +--- + +## Previous status (2026-10-02, Epic M under way) + +A full review (2026-10-01) found that what the note boards don't measure +is broken somewhere: lyric alignment, stems, beams, chord-symbol and voice +positions in MusicXML, invalid MusicXML (unescaped text, no ``), +Humdrum notation, retrograde, velocities and pitch in note arrays. The owner +chose to fix silent corruption first: Epic M is 0.5.0, P5 follows. + +--- + +## Previous status (2026-09-28, Epics K and L) **Epics K and L are done and released as v0.4.0.** Epic L (datasets) ships in it too, as the owner chose. Datasets read records as well as folders @@ -746,10 +770,37 @@ Planned for 0.5.0; the owner chose to ship it in 0.4.0 together with Epic K. | L3 | **Identity.** `FolderDataset.ids` (paths relative to the root); `return_ids=True` on the torch and tf adapters | S | ✅ 2026-09-28: `ids` on every dataset (records: their ids); `return_ids` on the two datasets and the two data loaders, whose batches gain the ids | | L4 | **Python API reference.** `docs/python-api.md` generated from or checked against the stubs: lytk has none today (README + `_core.pyi`). It states that `from_lilypond_string` and `from_lilypond_music_string` share one parse (the Music tree is lifted from the Score) | S | ✅ 2026-09-28: `scripts/python_api.py` writes it from the stubs and the datasets module, in the sections of `lytk.__all__`; tests fail when it is stale or when a public name of the extension has no stub | +### Epic M — 0.5.0: notation you can trust (✅ 2026-10-02) + +Planned 2026-10-01 from the full review (devlog 2026-10-02). Order after +M2, from the design review: M5 → M6 → M7 → M4 → M8 → M3 → M9 → M10 → M11 +(stems/beams and the kern spine layout come before lyrics, which depend on +both). Every task +writes its failing tests first (the review's repros are strict xfails in +`tests/test_review_regressions.py`), fixes the shared function all callers +use, and raises the notation board. JSON stays backward compatible. + +| Task | Description | Size | Status | +|------|-------------|------|--------| +| M0 | **Measure first.** `common::notation_signature` (lyrics, chord symbols, dynamics, clefs at their onsets); `tests/notation_fidelity.rs` boards for XML→{XML, LY, ABC, KRN, MIDI}, LY→LY, LY→XML; overfull-voice gate; lyric oracle against LilyPond's MIDI (`lilypond_oracle.rs`); the review's repros as Python tests | M | ✅ 2026-10-02 | +| M1 | **MusicXML reader positions.** Notes, harmonies and figures placed at the ``/`` cursor; voice-less `` only moves the cursor; part-name-display text | M | ✅ 2026-10-02 (also: mid-bar `` merged; kern spacers as `ryy`, pickups from the longest spine) | +| M2 | **MusicXML writer validity.** `` always; text escaped and entities decoded; ISO-8859-1 and Latin-1 input | S | ✅ 2026-10-02 (LY→XML board added: 35/35) | +| M3 | **Lyrics.** LilyPond lyric lexer, melismas, verses and voice targets; one verse grid for the LilyPond, ABC and MIDI writers (`ignoreMelismata`); MusicXML elision and verse names; MIDI syllabic; Humdrum `**text` | L | ✅ 2026-10-02 (also: NullVoice read and written for a part's other singing voices, timed `\lyricmode`, `Note.lyric_syllables`; no shared grid module: each writer's stream follows what its format can say) | +| M4 | **Stems and beams.** The IR keeps the source's; writers compute the rest with LilyPond's rules (staff positions, beam groups, voices, meter table); no inferred `\stemUp`/`[ ]` in LilyPond output; ABC and kern beams; retrograde | L | ✅ 2026-10-02 (typed stem/beam enums moved to P5+; kern `/` `\\` stems and user `beamExceptions` not read) | +| M5 | **LilyPond reader drops nothing silently.** Text scripts, placement, `\once`, `\partial N*M`, `\time 3,2 5/8`, `\afterGrace`, grace chords, single-note tremolo, text marks | M | ✅ 2026-10-02 (two-note tremolo too; grace slurs explicit) | +| M6 | **Music tree and representations.** Grace chords, transposition in the Music tree, per-part velocity, direction dynamics, one velocity table, sounding pitch | M | ✅ 2026-10-02 (ABC and kern transposition; kern writes later key/meter/clef changes; melisma and beam flags in the lift move to M3/M4) | +| M7 | **Chord symbols.** LilyPond chordmode syntax and positions, extended suffixes, ABC text vs chord | M | ✅ 2026-10-02 (also: the ly→ly Music path writes chord names; stacked MusicXML harmonies change during their note; ABC writes all-multi-voice staves, from M8) | +| M8 | **Writers.** LilyPond church modes, tempo once; ABC voices, hairpins, clefs; Humdrum staves and rests; MIDI channels and metadata; MusicXML placement | M | ✅ 2026-10-02 (also: mid-bar clefs keep their place, `<>` read and written, D.C. once, rehearsal text; kern dynamics/text/harmony/lyrics stay with the Humdrum epic) | +| M9 | **Transforms.** Retrograde moves key/time/clef changes; transposition prefers fewer accidentals | S | ✅ 2026-10-02 (also: mid-bar clefs mirrored, syllabic swapped in both trees; directions keep their offsets in the bar) | +| M10 | **CLI.** Every movement for ly→ly and transforms; warnings; `-I`; safe folder output | S | ✅ 2026-10-02 (also: `diff` compares timing; `positions` skips grace chords; abs2rel/rel2abs every movement) | +| M11 | **Release 0.5.0.** Docs state measured support | S | ✅ 2026-10-02 (`docs/import-export.md` opens with the measured boards; version 0.5.0; tagging left to the owner) | + ### After 0.4.0 -The pre-1.0 queue resumes: **P5** (enumerated notation types, the one -epic that breaks the Python API) as 0.5.0, then P3 → P4 → P6 → P8 → 1.0.0. +The pre-1.0 queue resumes after Epic M (0.5.0, owner decision 2026-10-01): +**P5** (enumerated notation types, the one epic that breaks the Python API) +as 0.6.0, widened to every enum-like string of the IR, then P3 → P4 → P6 → +P8 → 1.0.0. Decision for the owner: J–L go before P5 because they unblock lilycorpus and are backward compatible, whereas the 0.1.0 plan put P5 first. P3's import-options object should absorb J3/K2's keywords one to one: @@ -961,7 +1012,8 @@ notation-exported MIDI (IC). | **13** | **I → 0.2.0** | **MIDI and ABC conversion repair (Epic I, with P9 performed MIDI): tagged `v0.2.0` 2026-09-27** | | **14** | **J → 0.3.0** | **Trustworthy LilyPond reading: no panics, diagnostics + strict mode, whole strings and headers, pitch-language files: tagged `v0.3.0` 2026-09-27** | | **15** | **K + L → 0.4.0** | **Source-level API (`\version`, includes, tokens, statistics, movements) and datasets for curated corpora (records dataset, errors, ids, Python API reference): tagged `v0.4.0` 2026-09-28** | -| 16 | P5 → 0.5.0, then P3 → P4 → P6 → P8 → 1.0.0 | Pre-1.0 queue resumes (P5 breaks the Python API) | +| **16** | **M → 0.5.0** | **Notation you can trust (the 2026-10-01 review): measured lyrics, chord symbols, dynamics, clefs, stems and beams; MusicXML positions and validity; LilyPond lyrics, chord names, constructs; note arrays; writers; transforms; CLI movements** | +| 17 | P5+ → 0.6.0, then the Python API (0.7.0), Humdrum for real corpora (0.8.0), the ML layer (0.9.0), P3 → P4 → P6 → P8 → 1.0.0 | Pre-1.0 queue resumes (P5+ breaks the Python dict/JSON shape once) | ## Key Decisions diff --git a/src/adapters/abc_to_ir.rs b/src/adapters/abc_to_ir.rs index 41d89b7..faea3ac 100644 --- a/src/adapters/abc_to_ir.rs +++ b/src/adapters/abc_to_ir.rs @@ -20,12 +20,14 @@ use std::collections::HashMap; use std::path::Path; use crate::ir::annotation::Annotation; -use crate::ir::articulation::{Articulation, DynamicMark, Fermata, Ornament, Placement, Wedge}; +use crate::ir::articulation::{ + Articulation, DynamicMark, Fermata, Ornament, Placement, Technical, Wedge, +}; use crate::ir::direction::{ Barline, BarlineType, Direction, RepeatDirection, TempoDirection, TextDirection, }; use crate::ir::duration::{Duration, Frac}; -use crate::ir::harmony::{kind_from_suffix, ChordPitch, Harmony}; +use crate::ir::harmony::{parse_chord_suffix, ChordPitch, Harmony}; use crate::ir::measure::{Clef, ClefSign, KeyMode, KeySignature, TimeSignature}; use crate::ir::music::{ContextType, Music, MusicDocument}; use crate::ir::pitch::{Alter, Pitch, PitchStep}; @@ -71,13 +73,14 @@ impl AbcToIrAdapter { } pub fn convert_file_tunes(&self, path: &Path) -> Result> { - self.convert_str_tunes(&std::fs::read_to_string(path)?) + self.convert_str_tunes(&super::decode_text_bytes(std::fs::read(path)?)) } } impl ToMusicAdapter for AbcToIrAdapter { fn convert_file_to_music(&self, path: &Path) -> Result { - let text = std::fs::read_to_string(path)?; + // Older ABC collections are Latin-1. + let text = super::decode_text_bytes(std::fs::read(path)?); self.convert_str_to_music(&text) } @@ -217,6 +220,11 @@ struct VoiceStream { /// following `w:` line sings again as the next verse. lyric_from: usize, verse: Option<(usize, u8)>, + /// Verses whose last `w:` line ended inside a word (`haj-`). + open_words: Vec, + /// The last note of the beam being read (an index in `events`): the + /// next note written right after it joins it. + beam_from: Option, } /// Find the voice with `id`, creating it (recording `name` if given) when absent. @@ -248,6 +256,8 @@ fn ensure_voice( slurs: 0, lyric_from: 0, verse: None, + open_words: Vec::new(), + beam_from: None, }); voices.len() - 1 } @@ -259,6 +269,8 @@ struct VoiceField { name: Option, clef: Option, octave: Option, + /// `transpose=N`: the voice sounds `N` semitones from its written notes. + transpose: Option, } fn parse_voice_field(value: &str) -> VoiceField { @@ -268,9 +280,12 @@ fn parse_voice_field(value: &str) -> VoiceField { let name = extract_param(v, "name").or_else(|| extract_param(v, "nm")); let mut clef = None; let mut octave = None; + let mut transpose = None; for t in toks { if let Some(c) = t.strip_prefix("clef=").and_then(parse_clef) { clef = Some(c); + } else if let Some(n) = t.strip_prefix("transpose=") { + transpose = parse_transpose(n); } else if let Some(o) = t.strip_prefix("octave=") { octave = parse_octave(o); } else if !t.contains('=') { @@ -289,9 +304,24 @@ fn parse_voice_field(value: &str) -> VoiceField { name, clef, octave, + transpose, } } +/// `transpose=N`, bounded (an instrument transposes a few octaves at most). +fn parse_transpose(v: &str) -> Option { + v.parse::().ok().map(|n| n.clamp(-48, 48)) +} + +/// The events a voice's `clef=` and `transpose=` settings put in its music. +fn voice_settings(clef: Option, transpose: Option) -> Vec { + let mut out: Vec = clef.into_iter().map(Music::Clef).collect(); + out.extend( + transpose.map(|n| Music::Transposition(crate::ir::measure::Transpose::from_semitones(n))), + ); + out +} + /// Pull `key=value` (or `key="quoted value"`) from an ABC field parameter list. /// The key must start a token, so `name=` doesn't match `subname=`. fn extract_param(s: &str, key: &str) -> Option { @@ -350,9 +380,9 @@ fn switch_voice(voices: &mut Vec, value: &str, state: &TuneState) - if let Some(o) = vf.octave { voices[i].pitch.octave_shift = o; } - if let Some(c) = vf.clef { - voices[i].events.push(Music::Clef(c)); - } + voices[i] + .events + .extend(voice_settings(vf.clef, vf.transpose)); i } @@ -371,7 +401,7 @@ fn parse_tune(text: &str) -> Result { let mut voices: Vec = Vec::new(); // Clefs and octave shifts declared by header `V:` lines, applied once the // header's `K:` has set the key each voice starts from. - let mut header_voice_settings: Vec<(String, Option, Option)> = Vec::new(); + let mut header_voice_settings: Vec = Vec::new(); let mut current: usize = 0; let mut in_body = false; let mut seen_x = false; @@ -379,6 +409,8 @@ fn parse_tune(text: &str) -> Result { let mut header_tempo: Option = None; // The last body line was a `w:` line (the next one is another verse). let mut after_w = false; + // The last music line ended with `\`: this one goes on with it. + let mut continued = false; for raw in text.lines() { let line = raw.trim_end(); @@ -423,17 +455,15 @@ fn parse_tune(text: &str) -> Result { // Voice declaration in the header (ABC 2.1 §4.1): set up the // voice (and its name) ahead of the body. let vf = parse_voice_field(value); - ensure_voice(&mut voices, &vf.id, vf.name, &state); - header_voice_settings.push((vf.id, vf.clef, vf.octave)); + ensure_voice(&mut voices, &vf.id, vf.name.clone(), &state); + header_voice_settings.push(vf); } 'K' => { let k = parse_key_field(value); if let Some(sig) = k.signature { header_events.push(Music::KeySignature(sig)); } - if let Some(clef) = k.clef { - header_events.push(Music::Clef(clef)); - } + header_events.extend(voice_settings(k.clef, k.transpose)); apply_key(&mut state.default_pitch, &k); // Default unit length depends on the meter when L: is absent. if !explicit_unit_length { @@ -445,15 +475,11 @@ fn parse_tune(text: &str) -> Result { v.pitch = state.default_pitch.clone(); v.unit = state.unit_length; v.meter = state.meter; - if let Some((_, clef, octave)) = - header_voice_settings.iter().find(|(id, ..)| *id == v.id) - { - if let Some(o) = octave { - v.pitch.octave_shift = *o; - } - if let Some(c) = clef { - v.events.push(Music::Clef(*c)); + if let Some(vf) = header_voice_settings.iter().find(|vf| vf.id == v.id) { + if let Some(o) = vf.octave { + v.pitch.octave_shift = o; } + v.events.extend(voice_settings(vf.clef, vf.transpose)); } } // K: ends the header; the rest is the tune body. @@ -513,6 +539,9 @@ fn parse_tune(text: &str) -> Result { continue; } after_w = false; + // A `w:` line is sung on the music line above it (ABC 2.1 §5.1), + // with the lines a `\` joins to it. + let mut started: Vec = Vec::new(); // Inline `[V:id]` markers switch voices anywhere in the line. for (switch, music) in split_inline_voices(line) { if let Some(value) = switch { @@ -525,8 +554,18 @@ fn parse_tune(text: &str) -> Result { } current = ensure_voice(&mut voices, "1", None, &state); } + if !continued && !started.contains(¤t) { + voices[current].lyric_from = voices[current].events.len(); + started.push(current); + } parse_body_line(music, &mut state, &mut voices[current]); } + continued = line + .split('%') + .next() + .unwrap_or("") + .trim_end() + .ends_with('\\'); } } for v in &mut voices { @@ -674,9 +713,12 @@ fn is_field_line(line: &str) -> bool { fn split_field(line: &str) -> (char, &str) { let key = line.chars().next().unwrap(); let value = line[2..].trim(); - // Strip trailing inline comment. - let value = value.split('%').next().unwrap_or(value).trim(); - (key, value) + // Strip trailing inline comment (`\%` is a percent sign). + let cut = value + .char_indices() + .find(|&(i, c)| c == '%' && !value[..i].ends_with('\\')) + .map_or(value.len(), |(i, _)| i); + (key, value[..cut].trim()) } /// Apply a `K:`/`M:`/`L:` field met in the body (on its own line or inline as @@ -699,9 +741,7 @@ fn apply_inline_field(key: char, value: &str, state: &mut TuneState, voice: &mut if let Some(sig) = k.signature { voice.events.push(Music::KeySignature(sig)); } - if let Some(clef) = k.clef { - voice.events.push(Music::Clef(clef)); - } + voice.events.extend(voice_settings(k.clef, k.transpose)); apply_key(&mut voice.pitch, &k); } 'Q' => voice @@ -808,10 +848,8 @@ fn align_lyrics(voice: &mut VoiceStream, line: &str, again: bool) { }); } '~' => cur.push(' '), - '\\' if chars.peek() == Some(&'-') => { - chars.next(); - cur.push('-'); - } + // `\-`, `\_`, `\*`, …: the sign itself. + '\\' => cur.extend(chars.next()), _ => cur.push(c), } } @@ -844,7 +882,9 @@ fn align_lyrics(voice: &mut VoiceStream, line: &str, again: bool) { let notes = kinds.iter().filter(|k| **k).count(); let mut sung: Vec> = vec![None; notes]; let (mut slot, mut note) = (0, 0); - let (mut hyphen, mut last): (bool, Option) = (false, None); + // A word a verse's last line left open goes on here. + let mut hyphen = voice.open_words.contains(&verse); + let mut last: Option = None; for tok in toks { if let Tok::Bar = tok { // On to the note after the next bar line. @@ -870,11 +910,12 @@ fn align_lyrics(voice: &mut VoiceStream, line: &str, again: bool) { (true, false) => SyllabicType::End, }; sung[note] = Some(LyricSyllable { + elision: text.contains('\u{203F}'), text, syllabic, number: verse, extend: false, - elision: false, + name: None, }); (hyphen, last) = (h, Some(note)); } @@ -899,6 +940,10 @@ fn align_lyrics(voice: &mut VoiceStream, line: &str, again: bool) { }); voice.verse = Some((from, verse)); voice.lyric_from = voice.events.len(); + voice.open_words.retain(|v| *v != verse); + if hyphen { + voice.open_words.push(verse); + } } // --------------------------------------------------------------------------- @@ -991,6 +1036,7 @@ struct KeyField { alters: Option<[i32; 7]>, octave: Option, clef: Option, + transpose: Option, } /// Parse a `K:` value: `G`, `F#m`, `Bb mix`, `D Phr ^f`, `D exp _b _e ^f`, @@ -1027,8 +1073,10 @@ fn parse_key_field(value: &str) -> KeyField { out.octave = parse_octave(o); } else if let Some(c) = t.strip_prefix("clef=") { out.clef = parse_clef(c).or(out.clef); + } else if let Some(n) = t.strip_prefix("transpose=") { + out.transpose = parse_transpose(n); } else if t.contains('=') && !t.starts_with('=') { - // middle=, transpose=, stafflines=, …: layout or playback only. + // middle=, stafflines=, …: layout only. } else if let Some(c) = parse_clef(t) { out.clef = Some(c); } else if let Some((letter, alter)) = parse_key_accidental(t) { @@ -1225,6 +1273,8 @@ fn parse_body_line(line: &str, state: &mut TuneState, voice: &mut VoiceStream) { fn parse_music(line: &str, state: &mut TuneState, voice: &mut VoiceStream, grace: bool) { let chars: Vec = line.chars().collect(); let mut i = 0; + // A line's end ends a beam. + voice.beam_from = None; // Open tuplet: (actual, normal, sounding events still to collect, start index). let mut tuplet: Option<(u8, u8, usize, usize)> = None; // Broken rhythm (`A>B`): the factor the next note, chord or rest takes. @@ -1472,8 +1522,13 @@ fn parse_music(line: &str, state: &mut TuneState, voice: &mut VoiceStream, grace .count(); *rem = rem.saturating_sub(n); } + if !grace { + beam_by_spacing(voice, before, c); + } if matches!(tuplet, Some((_, _, 0, _))) { close_tuplet(&mut tuplet, &mut voice.events); + // The tuplet's notes moved into it: a beam goes no further. + voice.beam_from = None; } } // ponytail: a tuplet left open at end of line is closed here; ABC allows a @@ -1502,6 +1557,12 @@ fn decoration(name: &str) -> Option { placement: Placement::default(), })) }; + let technical = |n: &str, value: &str| { + Some(Annotation::Technical(Technical { + name: n.to_string(), + value: value.to_string(), + })) + }; let fermata = |inverted| { Some(Annotation::Fermata(Fermata { shape: "normal".to_string(), @@ -1530,18 +1591,20 @@ fn decoration(name: &str) -> Option { "invertedturn" => orn("inverted-turn"), "fermata" | "H" => fermata(false), "invertedfermata" => fermata(true), + "upbow" | "u" => technical("up-bow", ""), + "downbow" | "v" => technical("down-bow", ""), + "0" | "1" | "2" | "3" | "4" | "5" => technical("fingering", name), _ => None, } } -/// A quoted string: a chord symbol (`Am7`, `F#m7b5`, `G/B`) when it starts -/// with a note letter, else an annotation — `^` above, `_` below, the other -/// placements (`<`, `>`, `@`) above too. +/// A quoted string: a chord symbol (`Am7`, `F#m7b5`, `G/B`, `C6/9`) when it +/// reads as one, `N.C.` for none, else an annotation — `^` above, `_` below, +/// the other placements (`<`, `>`, `@`) and a plain string (`"Fine"`) above. fn quoted(text: &str) -> Option { - let mut chars = text.chars(); - let first = chars.next()?; - if let Some(words) = text.strip_prefix(['^', '_', '<', '>', '@']) { - return (!words.trim().is_empty()).then(|| { + let first = text.chars().next()?; + let annotation = |words: &str| -> Option { + (!words.trim().is_empty()).then(|| { Music::Direction(Box::new(Direction { text: Some(TextDirection { text: words.trim().to_string(), @@ -1555,15 +1618,27 @@ fn quoted(text: &str) -> Option { }), ..Direction::default() })) - }); + }) + }; + if let Some(words) = text.strip_prefix(['^', '_', '<', '>', '@']) { + return annotation(words); } let pitch = |s: &str| -> Option<(ChordPitch, usize)> { let step = s.chars().next().filter(|c| ('A'..='G').contains(c))?; - let (alter, n) = match s[1..].chars().next() { - Some('#' | '♯') => (1.0, 1 + s[1..].chars().next().map_or(0, char::len_utf8)), - Some('b' | '♭') => (-1.0, 1 + s[1..].chars().next().map_or(0, char::len_utf8)), - _ => (0.0, 1), + // Up to two of one accidental (`Fbb`, `C##`). + let sign = |c: char| match c { + '#' | '♯' => 1.0, + 'b' | '♭' => -1.0, + _ => 0.0, }; + let first = s[1..].chars().next().map_or(0.0, sign); + let signs: Vec = s[1..] + .chars() + .take_while(|&c| first != 0.0 && sign(c) == first) + .take(2) + .collect(); + let alter = first * signs.len() as f64; + let n = 1 + signs.iter().map(|c| c.len_utf8()).sum::(); Some(( ChordPitch { step: step.to_string(), @@ -1572,19 +1647,41 @@ fn quoted(text: &str) -> Option { n, )) }; - let (root, n) = pitch(text)?; - let (quality, bass) = match text[n..].split_once('/') { - Some((q, b)) => (q, pitch(b.trim()).map(|p| p.0)), - None => (&text[n..], None), + if matches!(text.trim(), "N.C." | "NC" | "N.C") { + return Some(Music::Harmony(Harmony { + root: ChordPitch { + step: "C".to_string(), + alter: 0.0, + }, + kind: "none".to_string(), + bass: None, + degrees: Vec::new(), + offset: 0, + function: None, + })); + } + let Some((root, n)) = pitch(text) else { + return annotation(text); + }; + // A bass after the last `/` (`G/B`); `/9` in `C6/9` is the suffix's. + let (quality, bass) = match text[n..].rsplit_once('/') { + Some((q, b)) if pitch(b.trim()).is_some_and(|(_, len)| len == b.trim().len()) => { + (q, pitch(b.trim()).map(|p| p.0)) + } + _ => (&text[n..], None), }; - Some(Music::Harmony(Harmony { - root, - kind: kind_from_suffix(quality.trim()).to_string(), - bass, - degrees: Vec::new(), - offset: 0, - function: None, - })) + match parse_chord_suffix(quality.trim()) { + Some((kind, degrees)) => Some(Music::Harmony(Harmony { + root, + kind: kind.to_string(), + bass, + degrees, + offset: 0, + function: None, + })), + // `"Fine"`, `"D.C. al Fine"`: words, not a chord. + None => annotation(text), + } } /// Put an annotation on the last note or chord (the last of a tuplet). @@ -1772,6 +1869,57 @@ fn parse_tuplet_spec(chars: &[char], start: usize, compound: bool) -> ((u8, u8, /// Close an open tuplet: wrap the events it collected in `Music::Tuplet` and /// stamp the ratio onto their durations (the rest of the IR reads it there). +/// ABC beams by spacing (§4.7): eighths and shorter written together are +/// beamed; a space, a rest or a bar line ends the beam. Every note's beaming +/// is the source's (`NoAutoBeam`). `c` is the character just read, the +/// events from `before` what it added. +fn beam_by_spacing(voice: &mut VoiceStream, before: usize, c: char) { + if matches!(c, ' ' | '\t') { + voice.beam_from = None; + return; + } + fn lead(m: &mut Music) -> Option<&mut Vec> { + match m { + Music::Note { annotations, .. } => Some(annotations), + Music::Chord { pitches, .. } => pitches.first_mut().map(|(_, a)| a), + _ => None, + } + } + for k in before.min(voice.events.len())..voice.events.len() { + let beamable = match &voice.events[k] { + Music::Note { duration, .. } | Music::Chord { duration, .. } => { + duration.base < Frac::new(1, 4) + } + Music::Harmony(_) | Music::Direction(_) | Music::Grace { .. } => continue, + _ => { + voice.beam_from = None; + continue; + } + }; + if let Some(a) = lead(&mut voice.events[k]) { + a.push(Annotation::NoAutoBeam); + } + if !beamable { + voice.beam_from = None; + continue; + } + if let Some(j) = voice.beam_from { + if let Some(a) = lead(&mut voice.events[j]) { + match a.iter().position(|x| *x == Annotation::BeamStop) { + Some(p) => { + a.remove(p); + } + None => a.push(Annotation::BeamStart), + } + } + if let Some(a) = lead(&mut voice.events[k]) { + a.push(Annotation::BeamStop); + } + } + voice.beam_from = Some(k); + } +} + fn close_tuplet(tuplet: &mut Option<(u8, u8, usize, usize)>, events: &mut Vec) { let Some((actual, normal, _, start)) = tuplet.take() else { return; @@ -2086,6 +2234,33 @@ mod tests { inner(&doc.music) } + /// Each note's beam marks: `[` begins, `]` ends, `.` none. + fn beam_marks(doc: &MusicDocument) -> String { + events(doc) + .iter() + .filter_map(|m| match m { + Music::Note { annotations, .. } => Some(annotations), + _ => None, + }) + .map(|a| { + assert!(a.contains(&Annotation::NoAutoBeam), "ABC decides beaming"); + if a.contains(&Annotation::BeamStart) { + '[' + } else if a.contains(&Annotation::BeamStop) { + ']' + } else { + '.' + } + }) + .collect() + } + + #[test] + fn notes_written_together_are_beamed() { + let doc = parse("X:1\nL:1/8\nK:C\ncdef g2 a b|c\"G\"d!p!e z f\n"); + assert_eq!(beam_marks(&doc), "[..]...[.]."); + } + fn notes(doc: &MusicDocument) -> Vec { events(doc) .iter() diff --git a/src/adapters/dynamics_velocity.rs b/src/adapters/dynamics_velocity.rs index 83cc36b..222dce5 100644 --- a/src/adapters/dynamics_velocity.rs +++ b/src/adapters/dynamics_velocity.rs @@ -1,38 +1,12 @@ //! Mappings between dynamic marks and MIDI velocities. //! //! Two ladders are in use: -//! - the **common** one (MuseScore, Finale: p 49, mf 80, f 96), used by the -//! note-array encodings and to read MIDI files written by other tools; +//! - the **common** one (MuseScore, Finale: p 49, mf 80, f 96), to read MIDI +//! files written by other tools; //! - **LilyPond's** (`ly/midi-init.ly`, `absolute-volume-alist` × 127: p 69, //! mf 86, f 95; 90 without a dynamic), used by lytk's MIDI export so that -//! `ly → MIDI` sounds like LilyPond's own MIDI, and to read files written -//! by LilyPond or lytk. - -/// Map a dynamic sign (e.g. `"mf"`, `"pp"`, `"sfz"`) to a MIDI velocity 1–127. -/// Unknown signs fall back to `mf` (80). -pub(crate) fn dynamic_to_velocity(sign: &str) -> u8 { - match sign { - "ppppp" => 5, - "pppp" => 10, - "ppp" => 16, - "pp" => 33, - "p" => 49, - "mp" => 64, - "mf" => 80, - "f" => 96, - "ff" => 112, - "fff" => 120, - "ffff" => 126, - "fffff" => 127, - // Accent-like / combined dynamics — treat as strong attacks. - "fp" => 96, - "sf" | "sfz" | "fz" => 112, - "sff" | "sffz" => 120, - "rfz" | "rf" => 112, - "sfp" | "sfpp" => 96, - _ => 80, - } -} +//! `ly → MIDI` sounds like LilyPond's own MIDI, by the note arrays so they +//! match that export, and to read files written by LilyPond or lytk. /// Quantize a MIDI velocity (0–127) to the nearest standard dynamic sign. pub(crate) fn velocity_to_dynamic(vel: u8) -> &'static str { @@ -80,6 +54,16 @@ pub(crate) fn lilypond_volume(sign: &str) -> f64 { .map_or(LILYPOND_DEFAULT_VOLUME, |(_, v)| *v) } +/// The velocity lytk's MIDI export plays a dynamic at before any instrument's +/// equalizer (LilyPond's volume × 127, truncated as the export does): the +/// note arrays' velocities, so they match what `to_midi` plays. +pub(crate) fn lilypond_velocity(sign: &str) -> u8 { + (lilypond_volume(sign) * 127.0) as u8 +} + +/// The velocity without any dynamic (LilyPond's default volume): 90. +pub(crate) const LILYPOND_DEFAULT_VELOCITY: u8 = 90; + /// The dynamic whose LilyPond velocity (volume × 127) is nearest to `vel`, for /// reading MIDI written by LilyPond or lytk. `sf` is an accent, not a level. pub(crate) fn lilypond_dynamic(vel: u8) -> &'static str { diff --git a/src/adapters/humdrum_to_ir.rs b/src/adapters/humdrum_to_ir.rs index d0abe8c..4f8c3f6 100644 --- a/src/adapters/humdrum_to_ir.rs +++ b/src/adapters/humdrum_to_ir.rs @@ -12,10 +12,11 @@ //! (`*^`, `*v`, `*x`). Non-kern spines (`**dynam`, `**text`, …) are skipped. //! Beam marks (`L`/`J`) and unmapped ornament characters are ignored. +use std::collections::HashMap; use std::path::Path; use crate::ir::annotation::Annotation; -use crate::ir::articulation::{Fermata, Placement}; +use crate::ir::articulation::{Articulation, Fermata, LyricSyllable, Placement, SyllabicType}; use crate::ir::direction::{Barline, BarlineType, RepeatDirection, TempoDirection}; use crate::ir::duration::{Duration, Frac}; use crate::ir::measure::{Clef, ClefSign, KeyMode, KeySignature, TimeSignature}; @@ -37,10 +38,9 @@ impl HumdrumToIrAdapter { impl ToMusicAdapter for HumdrumToIrAdapter { fn convert_file_to_music(&self, path: &Path) -> Result { - // Older kern corpora (e.g. Palestrina) are Latin-1; music content is - // ASCII, so lossy decoding only mangles comment/metadata bytes. - let bytes = std::fs::read(path)?; - self.convert_str_to_music(&String::from_utf8_lossy(&bytes)) + // Older kern corpora (e.g. Palestrina) are Latin-1. + let text = super::decode_text_bytes(std::fs::read(path)?); + self.convert_str_to_music(&text) } fn convert_str_to_music(&self, text: &str) -> Result { @@ -70,6 +70,8 @@ impl ToIrAdapter for HumdrumToIrAdapter { struct Spine { name: Option, events: Vec, + /// Beams open (`L` opens one level, `J` closes one). + beams: usize, } fn parse_kern(text: &str) -> Result { @@ -78,11 +80,14 @@ fn parse_kern(text: &str) -> Result { // `None` for non-kern spines we skip. let mut spines: Vec> = Vec::new(); let mut started = false; - // Anacrusis detection: duration of the first kern spine's events before - // the first barline, and the first declared bar length. - let mut lead_dur = Frac::from_integer(0); + // Anacrusis detection: the longest kern spine's music before the first + // barline (one voice may end early), and the first declared bar length. + let mut lead: Vec = Vec::new(); let mut saw_barline = false; let mut bar_len: Option = None; + let mut beamed = false; + // `**text`/`**silbe` columns: (kern column on their left, verse). + let mut text_spines: Vec> = Vec::new(); for raw in text.lines() { let line = raw.trim_end(); @@ -118,11 +123,26 @@ fn parse_kern(text: &str) -> Result { if !started { // Exclusive interpretation row establishes the spines. if tokens.iter().any(|t| t.starts_with("**")) { + // A text spine sings the kern spine on its left, a verse + // per text spine. + let mut verses: HashMap = HashMap::new(); + for (i, t) in tokens.iter().enumerate() { + let kern = tokens[..i].iter().rposition(|k| *k == "**kern"); + text_spines.push(match (*t, kern) { + ("**text" | "**silbe", Some(k)) => { + let v = verses.entry(k).or_insert(0); + *v = v.saturating_add(1); + Some((k, *v)) + } + _ => None, + }); + } for t in &tokens { spines.push(if *t == "**kern" { Some(Spine { name: None, events: Vec::new(), + beams: 0, }) } else { None @@ -171,7 +191,9 @@ fn parse_kern(text: &str) -> Result { } // Data row. - let first_kern = spines.iter().position(|s| s.is_some()); + if lead.len() < spines.len() { + lead.resize(spines.len(), Frac::from_integer(0)); + } for (i, t) in tokens.iter().enumerate() { let Some(Some(spine)) = spines.get_mut(i) else { continue; @@ -179,13 +201,51 @@ fn parse_kern(text: &str) -> Result { if *t == "." || t.is_empty() { continue; } - if let Some(event) = parse_data_token(t) { - if !saw_barline && Some(i) == first_kern { - lead_dur += event_whole_notes(&event); + if let Some(mut event) = parse_data_token(t) { + if !saw_barline { + lead[i] += event_whole_notes(&event); + } + // Beams: the outermost `L` begins a group, the `J` that + // closes the last level ends it (on a chord's first note). + let first = t.split_whitespace().next().unwrap_or(""); + let (opens, closes) = (first.matches('L').count(), first.matches('J').count()); + beamed |= opens + closes > 0; + let before = spine.beams; + spine.beams = (before + opens).saturating_sub(closes); + if let Some(a) = lead_annotations(&mut event) { + if before == 0 && opens > 0 { + a.push(Annotation::BeamStart); + } + if before > 0 && spine.beams == 0 { + a.push(Annotation::BeamStop); + } } spine.events.push(event); } } + // Syllables on the notes their kern spine has on this row. + for (i, t) in tokens.iter().enumerate() { + let Some(Some((k, verse))) = text_spines.get(i) else { + continue; + }; + if *t == "." || t.is_empty() || tokens.get(*k).is_none_or(|n| *n == ".") { + continue; + } + let Some(Some(spine)) = spines.get_mut(*k) else { + continue; + }; + if let Some(a) = spine.events.last_mut().and_then(lead_annotations) { + a.push(Annotation::Lyric(text_syllable(t, *verse))); + } + } + } + // A file that beams decides every note's beaming. + if beamed { + for e in spines.iter_mut().flatten().flat_map(|s| &mut s.events) { + if let Some(a) = lead_annotations(e) { + a.push(Annotation::NoAutoBeam); + } + } } // Anacrusis: content before the first barline shorter than a full bar. @@ -203,6 +263,10 @@ fn parse_kern(text: &str) -> Result { } }); } + let lead_dur = lead + .into_iter() + .max() + .unwrap_or_else(|| Frac::from_integer(0)); if let Some(bl) = bar_len { if lead_dur > Frac::from_integer(0) && lead_dur < bl { metadata.partial_duration = Some(Duration::new(lead_dur)); @@ -238,7 +302,7 @@ fn event_whole_notes(event: &Music) -> Frac { match event { Music::Note { duration, .. } => duration.actual_duration(), Music::Chord { duration, .. } => duration.actual_duration(), - Music::Rest { duration, .. } => duration.actual_duration(), + Music::Rest { duration, .. } | Music::Skip { duration } => duration.actual_duration(), _ => Frac::from_integer(0), } } @@ -247,6 +311,19 @@ fn event_whole_notes(event: &Music) -> Frac { fn parse_interpretation(t: &str, spine: &mut Spine) { if let Some(name) = t.strip_prefix("*I\"") { spine.name = Some(name.trim().to_string()); + } else if let Some(tr) = t.strip_prefix("*ITrd") { + // `*ITrd-1c-2`: the spine sounds 1 step, 2 semitones below. + let parsed = tr.split_once('c').and_then(|(d, c)| { + let d: i32 = d.parse().ok()?; + let c: i32 = c.parse().ok()?; + let o = d / 7; + Some(crate::ir::measure::Transpose { + diatonic: i8::try_from(d - 7 * o).ok()?, + chromatic: i8::try_from(c - 12 * o).ok()?, + octave_change: i8::try_from(o).ok()?, + }) + }); + spine.events.extend(parsed.map(Music::Transposition)); } else if let Some(clef) = t.strip_prefix("*clef") { if let Some(c) = parse_clef(clef) { spine.events.push(Music::Clef(c)); @@ -366,6 +443,45 @@ struct TokenFlags { slur_stop: bool, fermata: bool, grace: Option, // Some(slash?) + /// `y`: an invisible token (an invisible rest is a spacer). + hidden: bool, + /// Articulations, by MusicXML name. + articulations: Vec<&'static str>, +} + +/// A `**text` token: `Hal-` begins a word, `-le-` goes on with it, `-lu` +/// ends it. +fn text_syllable(token: &str, verse: u8) -> LyricSyllable { + let (continues, rest) = match token.strip_prefix('-') { + Some(r) if !r.is_empty() => (true, r), + _ => (false, token), + }; + let (text, goes_on) = match rest.strip_suffix('-') { + Some(t) if !t.is_empty() => (t, true), + _ => (rest, false), + }; + LyricSyllable { + text: text.to_string(), + syllabic: match (continues, goes_on) { + (false, false) => SyllabicType::Single, + (false, true) => SyllabicType::Begin, + (true, true) => SyllabicType::Middle, + (true, false) => SyllabicType::End, + }, + number: verse, + ..LyricSyllable::default() + } +} + +/// The annotations a note's beams go on: a note's, a chord's first note's +/// (a grace's too). +fn lead_annotations(m: &mut Music) -> Option<&mut Vec> { + match m { + Music::Note { annotations, .. } => Some(annotations), + Music::Chord { pitches, .. } => pitches.first_mut().map(|(_, a)| a), + Music::Grace { content, .. } => lead_annotations(content), + _ => None, + } } fn parse_data_token(token: &str) -> Option { @@ -376,11 +492,11 @@ fn parse_data_token(token: &str) -> Option { } let mut notes: Vec<(Pitch, Duration, TokenFlags)> = Vec::new(); - let mut rest: Option = None; + let mut rest: Option<(Duration, bool)> = None; for sub in &subtokens { let (dur, flags, body) = split_subtoken(sub); if body.contains('r') { - rest = Some(dur); + rest = Some((dur, flags.hidden)); continue; } let pitch = parse_kern_pitch(&body)?; @@ -388,10 +504,12 @@ fn parse_data_token(token: &str) -> Option { } if notes.is_empty() { - let dur = rest?; - return Some(Music::Rest { - duration: dur, - is_measure_rest: false, + return Some(match rest? { + (duration, true) => Music::Skip { duration }, + (duration, false) => Music::Rest { + duration, + is_measure_rest: false, + }, }); } @@ -412,6 +530,12 @@ fn parse_data_token(token: &str) -> Option { if f.slur_stop { a.push(Annotation::SlurStop { number: 1 }); } + for name in &f.articulations { + a.push(Annotation::Articulation(Articulation { + name: name.to_string(), + placement: Placement::Unspecified, + })); + } if f.fermata { a.push(Annotation::Fermata(Fermata { shape: String::new(), @@ -456,6 +580,8 @@ fn split_subtoken(sub: &str) -> (Duration, TokenFlags, String) { slur_stop: false, fermata: false, grace: None, + hidden: false, + articulations: Vec::new(), }; let mut digits = String::new(); let mut dots = 0u8; @@ -475,6 +601,15 @@ fn split_subtoken(sub: &str) -> (Duration, TokenFlags, String) { ';' => flags.fermata = true, 'q' => flags.grace = Some(true), 'Q' => flags.grace = Some(false), + 'y' => flags.hidden = true, + '\'' => flags.articulations.push("staccato"), + '`' => flags.articulations.push("staccatissimo"), + '~' => flags.articulations.push("tenuto"), + // `^` an accent, `^^` a heavy one. + '^' => match flags.articulations.last_mut() { + Some(a) if *a == "accent" => *a = "strong-accent", + _ => flags.articulations.push("accent"), + }, '0'..='9' => { if let Some((_, den)) = &mut rational { den.push(c); diff --git a/src/adapters/ir_to_abc.rs b/src/adapters/ir_to_abc.rs index 489521f..9c802f7 100644 --- a/src/adapters/ir_to_abc.rs +++ b/src/adapters/ir_to_abc.rs @@ -17,12 +17,12 @@ use crate::ir::annotation::Annotation; use crate::ir::articulation::{Placement, SyllabicType}; use crate::ir::direction::{Barline, BarlineType, RepeatDirection, TempoDirection}; use crate::ir::duration::{Duration, Frac}; -use crate::ir::harmony::{suffix_of_kind, Harmony}; -use crate::ir::measure::{KeyMode, KeySignature, TimeSignature}; +use crate::ir::harmony::{lead_sheet_suffix, Harmony}; +use crate::ir::measure::{Clef, ClefSign, KeyMode, KeySignature, TimeSignature}; use crate::ir::music::{ContextType, Music, MusicDocument, RepeatType}; use crate::ir::pitch::{respell, Pitch}; -use super::{FromMusicAdapter, Result}; +use super::{FromIrAdapter, FromMusicAdapter, Result}; /// Bars per output line — ABC convention, and it keeps lines readable. const BARS_PER_LINE: usize = 4; @@ -46,6 +46,37 @@ impl FromMusicAdapter for IrToAbcAdapter { } } +/// A score is written with its beams: ABC beams by spacing, so what the +/// source left to the engraver is engraved first. +impl FromIrAdapter for IrToAbcAdapter { + fn convert(&self, score: &crate::ir::score::Score) -> Result { + let mut score = score.clone(); + crate::ir::beams::engrave(&mut score); + emit_tune(&crate::ir::lift::lift_to_music(&score)) + } + + fn write(&self, score: &crate::ir::score::Score, path: &std::path::Path) -> Result<()> { + std::fs::write(path, self.convert(score)?)?; + Ok(()) + } +} + +/// Marks a token that joins the one before it (no space: a beam). +const GLUE: char = '\u{1}'; + +/// Whether a note or chord opens or closes a beam. +fn beam_marks(m: &Music) -> (bool, bool) { + let anns: &[Annotation] = match m { + Music::Note { annotations, .. } => annotations, + Music::Chord { pitches, .. } => pitches.first().map_or(&[], |(_, a)| a.as_slice()), + _ => &[], + }; + ( + anns.contains(&Annotation::BeamStart), + anns.contains(&Annotation::BeamStop), + ) +} + /// Most bar lines a voice is written with. A long note is tied over every /// bar line of the meter, so a note of millions of whole notes (hand-made IR) /// would be millions of tokens. @@ -115,12 +146,21 @@ fn emit_tune(doc: &MusicDocument) -> Result { if let Some(q) = first_tempo.as_ref().and_then(tempo_to_abc) { out.push_str(&format!("Q:{q}\n")); } + // One voice's opening clef goes on the K: line, several voices' on + // their V: lines. + let clef_setting = |events: &[Music]| { + opening_clef(events).map_or(String::new(), |c| format!(" clef={}", clef_to_abc(&c))) + }; out.push_str(&format!( - "K:{}\n", + "K:{}{}\n", first_key .as_ref() .map(key_to_abc) - .unwrap_or_else(|| "C".to_string()) + .unwrap_or_else(|| "C".to_string()), + match voices.as_slice() { + [one] => clef_setting(&one.events), + _ => String::new(), + } )); match voices.len() { @@ -145,9 +185,10 @@ fn emit_tune(doc: &MusicDocument) -> Result { // header, so a body repeats its leading time/key only if it differs. for (i, v) in voices.iter().enumerate() { let id = i + 1; + let clef = clef_setting(&v.events); match &v.name { - Some(n) => out.push_str(&format!("V:{id} name=\"{n}\"\n")), - None => out.push_str(&format!("V:{id}\n")), + Some(n) => out.push_str(&format!("V:{id} name=\"{n}\"{clef}\n")), + None => out.push_str(&format!("V:{id}{clef}\n")), } let body = emit_body( &v.events, @@ -180,7 +221,7 @@ fn emit_body( let mut tempo_used = false; // Marks a direction puts on the next note (a dynamic, a hairpin). let mut carried = String::new(); - let mut lyrics = Lyrics::default(); + let mut lyrics = Lyrics::new(events); let mut key_used = false; let mut acc = Accidentals::new(header_key.map_or(0, |k| k.fifths as i32)); // Bar accounting: the IR only carries explicit `Music::Barline` events for @@ -198,6 +239,12 @@ fn emit_body( // Sounding events still inside the open tuplet run (0 = not in a tuplet). let mut tuplet_left = 0usize; let mut queue: std::collections::VecDeque = events.iter().cloned().collect(); + // Inside a beam: what is written joins the note before it. + let mut in_beam = false; + // The hairpin open (`<` or `>`), which its end closes. + let mut hairpin: Option = None; + // Whether a note or rest has been written. + let mut sounded = false; // The other voices of the bar being written, as `&` layers (ABC 2.1 // §7.4) just before its bar line. let mut layers: Vec> = Vec::new(); @@ -281,6 +328,8 @@ fn emit_body( } None => tuplet_left = 0, } + let before = tokens.len(); + let glued = in_beam && !matches!(ev, Music::Barline(_)); match &ev { Music::TimeSignature(t) => { bar_len = Some(t.beats_fraction()); @@ -297,9 +346,20 @@ fn emit_body( } key_used = true; } + // A transposing instrument: sounds `transpose=` semitones away + // (ABC 2.1 §4.6). + Music::Transposition(t) => tokens.push(format!("[K:transpose={}]", t.semitones())), + // A clef change (ABC 2.1 §4.6); the clef a voice opens with is + // in its K: or V: line. + Music::Clef(c) => { + if sounded { + tokens.push(format!("[K:clef={}]", clef_to_abc(c))); + } + } Music::Note { .. } | Music::Chord { .. } | Music::Rest { .. } | Music::Skip { .. } => { if let Some(tok) = sounding_token(&ev, &mut acc, false) { - let (pre, post) = decorations(&ev); + sounded = true; + let (pre, post) = decorations(&ev, &mut hairpin); tokens.push(format!("{}{pre}{tok}{post}", std::mem::take(&mut carried))); lyrics.note(&ev); } @@ -319,7 +379,7 @@ fn emit_body( carried.push_str(&format!("!{}!", dm.sign)); } if let Some(w) = &d.wedge { - carried.push_str(wedge_sign(&w.wedge_type)); + carried.push_str(wedge_sign(&w.wedge_type, &mut hairpin)); } } Music::Tempo(t) => { @@ -367,6 +427,17 @@ fn emit_body( } _ => {} } + if glued { + for t in tokens[before..].iter_mut().filter(|t| *t != "\n") { + t.insert(0, GLUE); + } + } + match beam_marks(&ev) { + (true, _) => in_beam = true, + (_, true) => in_beam = false, + _ if matches!(ev, Music::Barline(_)) => in_beam = false, + _ => {} + } // Regular bar line: close the bar as soon as the meter's worth of time // has been emitted (explicit barlines above reset the count themselves). if let (Some(len), Some(d)) = (this_bar.or(bar_len), sounding_duration(&ev)) { @@ -397,7 +468,12 @@ fn emit_body( tokens.extend(words); } // Join on spaces, but keep the line breaks we inserted as real newlines. - Ok(tokens.join(" ").replace(" \n ", "\n").replace(" \n", "\n")) + Ok(tokens + .join(" ") + .replace(&format!(" {GLUE}"), "") + .replace(GLUE, "") + .replace(" \n ", "\n") + .replace(" \n", "\n")) } /// Write the bar's other voices, each after a `&`, with the accidentals the @@ -405,6 +481,7 @@ fn emit_body( fn push_layers(tokens: &mut Vec, layers: &mut Vec>, acc: &mut Accidentals) { for layer in layers.drain(..) { tokens.push("&".to_string()); + let mut hairpin = None; let mut left = 0usize; for (k, m) in layer.iter().enumerate() { match tuplet_ratio(m) { @@ -426,7 +503,7 @@ fn push_layers(tokens: &mut Vec, layers: &mut Vec>, acc: &mut Music::Grace { content, slash } => tokens.extend(grace_token(content, *slash, acc)), _ => { if let Some(tok) = sounding_token(m, acc, false) { - let (pre, post) = decorations(m); + let (pre, post) = decorations(m, &mut hairpin); tokens.push(format!("{pre}{tok}{post}")); } } @@ -535,6 +612,41 @@ struct Lyrics { } impl Lyrics { + /// Lyrics for these events: every verse gets a `w:` line under every + /// music line, so a reader that sings `w:` lines one after another and + /// one that sings each on the line above (ABC 2.1) read the same. + fn new(events: &[Music]) -> Self { + fn most(m: &Music) -> u8 { + let own = match m { + Music::Note { annotations, .. } => annotations.iter().collect::>(), + Music::Chord { + pitches, + annotations, + .. + } => annotations + .iter() + .chain(pitches.iter().flat_map(|(_, a)| a)) + .collect(), + _ => Vec::new(), + }; + own.iter() + .filter_map(|a| match a { + Annotation::Lyric(l) => Some(l.number.max(1)), + _ => None, + }) + .chain(m.children().iter().map(most)) + .chain(m.inner().map(most)) + .max() + .unwrap_or(0) + } + let verses = events.iter().map(most).max().unwrap_or(0) as usize; + Lyrics { + verses: vec![Vec::new(); verses], + notes: 0, + held: vec![false; verses], + } + } + fn note(&mut self, m: &Music) { let anns: Vec<&Annotation> = match m { Music::Note { annotations, .. } => annotations.iter().collect(), @@ -552,13 +664,16 @@ impl Lyrics { if let Annotation::Lyric(l) = a { let v = (l.number.max(1) - 1) as usize; if self.verses.len() <= v { - self.verses.resize(v + 1, Vec::new()); + self.verses.resize(v + 1, vec!["*".to_string(); self.notes]); self.held.resize(v + 1, false); } if self.verses[v].len() <= self.notes { - let text = l.text.replace(' ', "~").replace('-', "\\-"); let hyphen = matches!(l.syllabic, SyllabicType::Begin | SyllabicType::Middle); - self.verses[v].push(format!("{text}{}", if hyphen { "-" } else { "" })); + self.verses[v].push(format!( + "{}{}", + lyric_to_abc(&l.text), + if hyphen { "-" } else { "" } + )); self.held[v] = l.extend; } } @@ -571,14 +686,18 @@ impl Lyrics { } } - /// The `w:` lines for the notes so far, then a fresh line. + /// The `w:` lines for the notes so far, then a fresh line: one per + /// verse (they are numbered by their order), `*` for each note a verse + /// doesn't sing. fn lines(&mut self) -> Vec { let mut out = Vec::new(); - for words in self.verses.iter_mut() { - if words.iter().any(|w| w != "*" && w != "_") { + if self.notes > 0 { + for words in &mut self.verses { out.push(format!("w: {}", words.join(" "))); out.push("\n".to_string()); } + } + for words in &mut self.verses { words.clear(); } self.notes = 0; @@ -586,9 +705,27 @@ impl Lyrics { } } +/// A syllable for a `w:` line: words sung on one note joined with `~` (or +/// the `‿` they are printed with), the +/// signs a `w:` line reads (`- _ * ~ | %`) escaped. +fn lyric_to_abc(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + ' ' => out.push('~'), + '-' | '_' | '*' | '~' | '|' | '%' | '\\' => { + out.push('\\'); + out.push(c); + } + c => out.push(c), + } + } + out +} + /// Decorations and slur starts written before a note, slur ends after it /// (ABC 2.1 §4.14). -fn decorations(m: &Music) -> (String, String) { +fn decorations(m: &Music, hairpin: &mut Option) -> (String, String) { let anns: Vec<&Annotation> = match m { Music::Note { annotations, .. } => annotations.iter().collect(), Music::Chord { @@ -606,7 +743,7 @@ fn decorations(m: &Music) -> (String, String) { for a in anns { let sign = match a { Annotation::Dynamic(d) => format!("!{}!", d.sign), - Annotation::Wedge(w) => wedge_sign(&w.wedge_type).to_string(), + Annotation::Wedge(w) => wedge_sign(&w.wedge_type, hairpin).to_string(), Annotation::Articulation(a) => match a.name.as_str() { "staccato" => ".", "accent" => "!>!", @@ -626,6 +763,17 @@ fn decorations(m: &Music) -> (String, String) { _ => "", } .to_string(), + Annotation::Technical(t) => match t.name.as_str() { + "fingering" if matches!(t.value.as_str(), "0" | "1" | "2" | "3" | "4" | "5") => { + format!("!{}!", t.value) + } + "up-bow" => "!upbow!".to_string(), + "down-bow" => "!downbow!".to_string(), + _ => continue, + }, + Annotation::Fingering(f) if matches!(f.as_str(), "0" | "1" | "2" | "3" | "4" | "5") => { + format!("!{f}!") + } Annotation::Fermata(f) => if f.inverted { "!invertedfermata!" } else { @@ -652,26 +800,86 @@ fn decorations(m: &Music) -> (String, String) { (pre, post) } -fn wedge_sign(kind: &str) -> &'static str { +/// The clef a voice's events set before its first note or rest. +fn opening_clef(events: &[Music]) -> Option { + fn first(events: &[Music]) -> Option> { + for e in events { + match e { + Music::Clef(c) => return Some(Some(*c)), + Music::Sequential(inner) => { + if let Some(found) = first(inner) { + return Some(found); + } + } + Music::Context { content, .. } => { + if let Some(found) = first(std::slice::from_ref(content)) { + return Some(found); + } + } + m if sounding_duration(m).is_some() || matches!(m, Music::Simultaneous(_)) => { + return Some(None) + } + _ => {} + } + } + None + } + first(events).flatten() +} + +/// An ABC clef name: `bass`, `alto`, `bass3`. An octave clef is written +/// without its `-8`: ABC's player transposes under one, and the notes are +/// written as they sound. +// ponytail: `treble-8` with every pitch written an octave up would draw the 8. +fn clef_to_abc(c: &Clef) -> String { + let (name, line) = match c.sign { + ClefSign::G => ("treble", 2), + ClefSign::F => ("bass", 4), + ClefSign::C if c.line == 4 => ("tenor", 4), + ClefSign::C => ("alto", 3), + ClefSign::Percussion | ClefSign::Tab => ("perc", c.line), + }; + let line = if c.line == line { + String::new() + } else { + c.line.to_string() + }; + format!("{name}{line}") +} + +/// A hairpin's start, or the end of the one `open` (`!>)!` ends a +/// diminuendo). +fn wedge_sign(kind: &str, open: &mut Option) -> &'static str { match kind { - "crescendo" => "!<(!", - "diminuendo" | "decrescendo" => "!>(!", - _ => "!<)!", + "crescendo" => { + *open = Some('<'); + "!<(!" + } + "diminuendo" | "decrescendo" => { + *open = Some('>'); + "!>(!" + } + _ => match open.take() { + Some('>') => "!>)!", + _ => "!<)!", + }, } } /// `Am7`, `F#m7b5`, `G/B`. fn chord_symbol(h: &Harmony) -> String { let alter = |a: f64| match a.round() as i32 { - 1 => "#", - -1 => "b", - _ => "", + n if n > 0 => "#".repeat(n as usize), + n => "b".repeat(n.unsigned_abs() as usize), }; + if h.kind == "none" { + return "N.C.".to_string(); + } let mut s = format!( "{}{}{}", h.root.step, alter(h.root.alter), - suffix_of_kind(&h.kind) + lead_sheet_suffix(&h.kind, &h.degrees) ); if let Some(b) = &h.bass { s.push_str(&format!("/{}{}", b.step, alter(b.alter))); @@ -755,7 +963,9 @@ fn has_audible(events: &[Music]) -> bool { matches!( m, Music::Note { .. } | Music::Chord { .. } | Music::Rest { .. } - ) + ) || has_audible(m.children()) + || m.inner() + .is_some_and(|i| has_audible(std::slice::from_ref(i))) }) } @@ -1088,29 +1298,22 @@ fn key_to_abc(key: &KeySignature) -> String { if key.fifths.abs() > 7 { return "C".to_string(); } - let (suffix, offset) = match key.mode { - KeyMode::Major | KeyMode::Ionian => ("", 0), - KeyMode::Minor | KeyMode::Aeolian => ("m", -3), - KeyMode::Dorian => ("dor", -2), - KeyMode::Phrygian => ("phr", -4), - KeyMode::Lydian => ("lyd", 1), - KeyMode::Mixolydian => ("mix", -1), - KeyMode::Locrian => ("loc", -5), + let suffix = match key.mode { + KeyMode::Major | KeyMode::Ionian => "", + KeyMode::Minor | KeyMode::Aeolian => "m", + KeyMode::Dorian => "dor", + KeyMode::Phrygian => "phr", + KeyMode::Lydian => "lyd", + KeyMode::Mixolydian => "mix", + KeyMode::Locrian => "loc", }; - let tonic_fifths = key.fifths - offset; - format!("{}{suffix}", tonic_name(tonic_fifths)) -} - -/// Map a circle-of-fifths position to a (major-key) tonic name. Modes reach -/// past the major keys (G♯ minor is the tonic 8 fifths up, F♭ lydian 8 down). -fn tonic_name(fifths: i8) -> String { - let i = fifths as i32 + 1; // F = 0 - let letter = ['F', 'C', 'G', 'D', 'A', 'E', 'B'][i.rem_euclid(7) as usize]; - match i.div_euclid(7) { - 0 => letter.to_string(), - n if n > 0 => format!("{letter}{}", "#".repeat(n as usize)), - n => format!("{letter}{}", "b".repeat((-n) as usize)), - } + let (step, alter) = key.tonic(); + let sign = if alter < 0 { "b" } else { "#" }; + format!( + "{}{}{suffix}", + step.name(), + sign.repeat(alter.unsigned_abs() as usize) + ) } #[cfg(test)] diff --git a/src/adapters/ir_to_humdrum.rs b/src/adapters/ir_to_humdrum.rs index 9ba1f37..2d43d8e 100644 --- a/src/adapters/ir_to_humdrum.rs +++ b/src/adapters/ir_to_humdrum.rs @@ -34,7 +34,11 @@ impl IrToHumdrumAdapter { impl FromIrAdapter for IrToHumdrumAdapter { fn convert(&self, score: &Score) -> Result { - Ok(emit_kern(score)) + // Kern spells beams out (`L`, `J`): what the source left to the + // engraver is engraved first. + let mut score = score.clone(); + crate::ir::beams::engrave(&mut score); + Ok(emit_kern(&score)) } fn write(&self, score: &Score, path: &Path) -> Result<()> { @@ -65,6 +69,38 @@ struct SpineSrc<'a> { voice: u8, } +impl SpineSrc<'_> { + /// The verses the voice's notes sing. + fn verses(&self) -> Vec { + let set: std::collections::BTreeSet = self + .part + .measures + .iter() + .flat_map(|m| &m.voices) + .filter(|v| v.number == self.voice) + .flat_map(|v| &v.elements) + .flat_map(|e| e.notes().first()) + .flat_map(|n| &n.lyrics) + .map(|l| l.number) + .collect(); + set.into_iter().collect() + } + + /// The staff the voice is written on (its first element's). + fn staff(&self) -> u8 { + self.part + .measures + .iter() + .flat_map(|m| &m.voices) + .filter(|v| v.number == self.voice) + .flat_map(|v| &v.elements) + .map(VoiceElement::staff) + .next() + .unwrap_or(1) + .max(1) + } +} + fn emit_kern(score: &Score) -> String { // Spine per (part, voice), reversed so the top part is rightmost. let mut spines: Vec = Vec::new(); @@ -94,67 +130,73 @@ fn emit_kern(score: &Score) -> String { out.push_str(&format!("!!!OTL: {t}\n")); } - let row = |cells: Vec| cells.join("\t") + "\n"; + // Each spine's verses: a `**text` spine each, right of its kern spine. + let verses: Vec> = spines.iter().map(SpineSrc::verses).collect(); + // A row: each kern spine's cell, then its text spines' (`text(spine, + // verse)`). + let row_with = |kern: Vec, text: &dyn Fn(usize, u8) -> String| { + let mut cells = Vec::new(); + for (i, cell) in kern.into_iter().enumerate() { + cells.push(cell); + cells.extend(verses[i].iter().map(|&v| text(i, v))); + } + cells.join("\t") + "\n" + }; + // Most rows say the same in a text spine as in its kern spine. + let row = |kern: Vec| { + let same = kern.clone(); + row_with(kern, &|i, _| same[i].clone()) + }; + let interp = |kern: Vec| row_with(kern, &|_, _| "*".to_string()); let n = spines.len(); - out.push_str(&row(vec!["**kern".to_string(); n])); + out.push_str(&row_with(vec!["**kern".to_string(); n], &|_, _| { + "**text".to_string() + })); - // Instrument names (only where the part declares one). - if spines.iter().any(|s| !s.part.name.is_empty()) { - out.push_str(&row(spines - .iter() - .map(|s| { - if s.part.name.is_empty() { - "*".to_string() - } else { - format!("*I\"{}", s.part.name) - } - }) - .collect())); - } - - // Clef / key / time from each spine's first measure attributes. - let first_attrs = |s: &SpineSrc| s.part.measures.first().and_then(|m| m.attributes.clone()); - if spines + // Parts and staves, numbered from the top of the score. + let parts = score.parts(); + let first_staff: Vec = parts .iter() - .any(|s| first_attrs(s).is_some_and(|a| !a.clefs.is_empty())) - { - out.push_str(&row(spines + .scan(0u8, |next, p| { + let first = *next; + *next = next.saturating_add(p.staves.max(1)); + Some(first) + }) + .collect(); + let number = |s: &SpineSrc| { + parts .iter() - .map(|s| { - first_attrs(s) - .and_then(|a| a.clefs.values().next().cloned()) - .map(|c| format!("*clef{}", clef_str(&c))) - .unwrap_or_else(|| "*".to_string()) - }) - .collect())); - } - if spines + .position(|p| std::ptr::eq(*p, s.part)) + .unwrap_or(0) + }; + out.push_str(&row(spines .iter() - .any(|s| first_attrs(s).is_some_and(|a| a.key.is_some())) - { - out.push_str(&row(spines - .iter() - .map(|s| { - first_attrs(s) - .and_then(|a| a.key) - .map(|k| format!("*k[{}]", key_str(&k))) - .unwrap_or_else(|| "*".to_string()) - }) - .collect())); - } - if spines + .map(|s| format!("*part{}", number(s) + 1)) + .collect())); + out.push_str(&row(spines .iter() - .any(|s| first_attrs(s).is_some_and(|a| a.time.is_some())) - { - out.push_str(&row(spines - .iter() - .map(|s| { - first_attrs(s) - .and_then(|a| a.time.clone()) - .map(|t| time_str(&t)) - .unwrap_or_else(|| "*".to_string()) - }) - .collect())); + .map(|s| format!("*staff{}", first_staff[number(s)] + s.staff())) + .collect())); + + // Instrument names (only where the part declares one). + if spines.iter().any(|s| !s.part.name.is_empty()) { + out.push_str(&interp( + spines + .iter() + .map(|s| { + if s.part.name.is_empty() { + "*".to_string() + } else { + format!("*I\"{}", s.part.name) + } + }) + .collect(), + )); + } + + // Clef, key, meter and transposition of the first bar. + for r in interpretation_rows(&spines, 0) { + out.push_str(&interp(r)); } // Measures: aligned time slices with `.` padding. @@ -164,32 +206,82 @@ fn emit_kern(score: &Score) -> String { .max() .unwrap_or(0); for mi in 0..measure_count { + // What changes at this bar (the first bar's are above). + if mi > 0 { + for r in interpretation_rows(&spines, mi) { + out.push_str(&interp(r)); + } + } // Per spine: onset (in whole notes from measure start) → tokens. // Several events can share an onset — a grace note has zero duration, // so it sits on the same onset as the note it decorates. Each gets its // own kern data record (the Humdrum spelling), so the value is a Vec: // keying by onset alone silently dropped every grace note. - let mut streams: Vec>> = Vec::with_capacity(n); + // Each token with the note it writes, for its syllables. + type Stream<'a> = BTreeMap)>>; + let mut streams: Vec = Vec::with_capacity(n); + let mut ends: Vec = Vec::with_capacity(n); for s in &spines { - let mut events: BTreeMap> = BTreeMap::new(); + let mut events: Stream = BTreeMap::new(); + let mut end = Frac::from_integer(0); if let Some(measure) = s.part.measures.get(mi) { for voice in measure.voices.iter().filter(|v| v.number == s.voice) { let mut onset = Frac::from_integer(0); for elem in &voice.elements { let (token, dur) = element_token(elem); if let Some(tok) = token { - events.entry(onset).or_default().push(tok); + events + .entry(onset) + .or_default() + .push((tok, elem.notes().first())); } onset += dur; } + end = end.max(onset); } } streams.push(events); + ends.push(end); } - let mut onsets: Vec = streams.iter().flat_map(|m| m.keys().copied()).collect(); + // A voice absent from the bar, or ending early, is silent there: an + // invisible rest (`.` would mean its last note goes on). + let bar = ends.iter().copied().max().unwrap_or_default(); + for (events, end) in streams.iter_mut().zip(&ends) { + if *end < bar { + let rest = format!("{}ryy", recip_str(&Duration::new(bar - *end))); + events.entry(*end).or_default().push((rest, None)); + } + } + // Clef changes inside the bar, per spine. + let clefs: Vec> = spines + .iter() + .map(|s| { + s.part + .measures + .get(mi) + .into_iter() + .flat_map(|m| &m.directions) + .filter(|d| d.staff.max(1) == s.staff()) + .filter_map(|d| Some((d.offset_frac, format!("*clef{}", clef_str(&d.clef?))))) + .collect() + }) + .collect(); + let mut onsets: Vec = streams + .iter() + .flat_map(|m| m.keys().copied()) + .chain(clefs.iter().flat_map(|c| c.keys().copied())) + .collect(); onsets.sort(); onsets.dedup(); for onset in onsets { + if clefs.iter().any(|c| c.contains_key(&onset)) { + out.push_str(&interp( + clefs + .iter() + .map(|c| c.get(&onset).cloned().unwrap_or_else(|| "*".to_string())) + .collect(), + )); + } let depth = streams .iter() .map(|m| m.get(&onset).map_or(0, |v| v.len())) @@ -198,15 +290,20 @@ fn emit_kern(score: &Score) -> String { // Bottom-align: the leading rows hold the graces, the last row holds // the metrical event every spine shares. for k in 0..depth { - out.push_str(&row(streams - .iter() - .map(|m| { - m.get(&onset) - .and_then(|v| k.checked_sub(depth - v.len()).and_then(|i| v.get(i))) - .cloned() - .unwrap_or_else(|| ".".to_string()) - }) - .collect())); + let cell = |s: usize| { + streams[s] + .get(&onset) + .and_then(|v| k.checked_sub(depth - v.len()).and_then(|i| v.get(i))) + }; + let kern = (0..n) + .map(|s| cell(s).map_or_else(|| ".".to_string(), |c| c.0.clone())) + .collect(); + out.push_str(&row_with(kern, &|i, verse| { + cell(i) + .and_then(|c| c.1) + .and_then(|n| n.lyrics.iter().find(|l| l.number == verse)) + .map_or_else(|| ".".to_string(), text_token) + })); } } if mi + 1 < measure_count { @@ -225,6 +322,58 @@ fn emit_kern(score: &Score) -> String { out } +/// The interpretation rows for what measure `mi` sets, one row per kind +/// (clef, key, meter, transposition), `*` in the spines it doesn't touch. +fn interpretation_rows(spines: &[SpineSrc], mi: usize) -> Vec> { + (0..4) + .map(|kind| { + spines + .iter() + .map(|s| interpretation(s, mi, kind)) + .collect::>() + }) + .filter(|cells| cells.iter().any(Option::is_some)) + .map(|cells| { + cells + .into_iter() + .map(|c| c.unwrap_or_else(|| "*".to_string())) + .collect() + }) + .collect() +} + +/// A syllable in a `**text` spine: `Hal-`, `-le-`, `-lu`, `jah`. +fn text_token(l: &crate::ir::articulation::LyricSyllable) -> String { + use crate::ir::articulation::SyllabicType; + let text = l.text.replace(['\t', '\n'], " "); + match l.syllabic { + SyllabicType::Single => text, + SyllabicType::Begin => format!("{text}-"), + SyllabicType::Middle => format!("-{text}-"), + SyllabicType::End => format!("-{text}"), + } +} + +/// What measure `mi` sets for a spine: its clef (0), key (1), meter (2) or +/// transposition (3), as a kern interpretation. +fn interpretation(s: &SpineSrc, mi: usize, kind: usize) -> Option { + let a = s.part.measures.get(mi)?.attributes.as_ref()?; + match kind { + 0 => a + .clefs + .get(&s.staff()) + .map(|c| format!("*clef{}", clef_str(c))), + 1 => a.key.map(|k| format!("*k[{}]", key_str(&k))), + 2 => a.time.as_ref().map(time_str), + // A transposing instrument: `*ITrd-1c-2` (B♭) sounds a major second + // below what is written. + _ => a.transpose.map(|t| { + let d = i32::from(t.diatonic) + 7 * i32::from(t.octave_change); + format!("*ITrd{d}c{}", t.semitones()) + }), + } +} + /// The repeat signs of the bar line after measure `mi`: `:|!` ends a repeat /// there, `!|:` starts one in the next measure. fn repeat_str(part: &Part, mi: usize) -> &'static str { @@ -270,8 +419,12 @@ fn element_token(elem: &VoiceElement) -> (Option, Frac) { } VoiceElement::Rest(rest) => { if rest.is_spacer { - // Spacers occupy time but have no kern spelling; pad with `.`. - (None, rest.duration.actual_duration()) + // A spacer is an invisible rest (`yy`): a null token would + // mean the note before goes on. + ( + Some(format!("{}ryy", recip_str(&rest.duration))), + rest.duration.actual_duration(), + ) } else { ( Some(format!("{}r", recip_str(&rest.duration))), @@ -308,6 +461,31 @@ fn note_token(note: &Note) -> String { if note.is_grace { tok.push(if note.grace_slash { 'q' } else { 'Q' }); } + // Articulations and a fermata. + for a in ¬e.articulations { + tok.push_str(match a.name.as_str() { + "staccato" => "'", + "staccatissimo" => "`", + "accent" => "^", + "strong-accent" => "^^", + "tenuto" => "~", + _ => "", + }); + } + if note.fermata.is_some() { + tok.push(';'); + } + // Beams, a sign per level: `L` begins, `J` ends, `K`/`k` hooks forward + // and back. + for b in ¬e.beams { + match b.beam_type.as_str() { + "begin" => tok.push('L'), + "end" => tok.push('J'), + "forward hook" => tok.push('K'), + "backward hook" => tok.push('k'), + _ => {} + } + } if tie_start && tie_stop { tok.push('_'); } else if tie_stop { @@ -403,6 +581,38 @@ mod tests { (before, after) } + #[test] + fn beams_are_written_and_read() { + // Kern given no beams: the writer engraves them (2/4: by the beat). + let kern = "**kern\n*M2/4\n8c\n8d\n16e\n16f\n8g\n*-\n"; + let (_, after) = roundtrip(kern); + let emitted = IrToHumdrumAdapter::new() + .convert(&HumdrumToIrAdapter::new().convert_str(kern).unwrap()) + .unwrap(); + let data: Vec<&str> = emitted + .lines() + .filter(|l| l.starts_with(|c: char| c.is_ascii_digit())) + .collect(); + assert_eq!( + data, + ["8cL", "8dJ", "16eLL", "16fJ", "8gJ"].map(|t| t.to_string()) + ); + let firsts: Vec = after.parts()[0] + .measures + .iter() + .flat_map(|m| &m.voices) + .flat_map(|v| &v.elements) + .map(|e| { + let n = &e.notes()[0]; + assert!(n.no_auto_beam); + n.beams + .first() + .map_or(String::new(), |b| b.beam_type.clone()) + }) + .collect(); + assert_eq!(firsts, ["begin", "end", "begin", "", "end"]); + } + fn pitch_seq(score: &Score) -> Vec<(PitchStep, i32, i32)> { score .parts() diff --git a/src/adapters/ir_to_ly/emit.rs b/src/adapters/ir_to_ly/emit.rs index bd4c460..56708ae 100644 --- a/src/adapters/ir_to_ly/emit.rs +++ b/src/adapters/ir_to_ly/emit.rs @@ -9,7 +9,7 @@ use crate::ir::voice::Voice; use crate::ir::Part; use super::maps::{ - articulation_to_ly, clef_to_ly, duration_to_ly, key_to_ly, ornament_to_ly, partial_to_ly, + articulation_to_ly, clef_to_ly, duration_to_ly, key_to_ly, length_to_ly, ornament_to_ly, pitch_to_ly, tempo_to_ly, time_to_ly, tremolo_suffix, }; @@ -23,6 +23,19 @@ pub(super) struct EmitState { /// are written after its bar check (a bar check between two grace /// groups aborts LilyPond). pub(super) grace_carry: Option, + /// The main voice's stem command in force ("" for none). + pub(super) stem: String, + /// `\mark \default` marks written so far. + pub(super) marks: u32, +} + +/// A bar's marks for its first voice, by place in the bar: commands +/// written before the note there (`\\mark`, `\\tempo`, `\\break`) and +/// post-events after it (dynamics, hairpins, text). +#[derive(Default)] +struct Marks { + before: std::collections::BTreeMap>, + after: std::collections::BTreeMap>, } /// Extract the tuplet display hint from a voice element, if present. @@ -50,6 +63,7 @@ pub(super) fn emit_measures( lang: PitchLanguage, mode: PitchMode, staff_filter: Option, + lead: Option, partial_dur: Option<&Duration>, relative_ref: Option<&Pitch>, indent: usize, @@ -57,6 +71,9 @@ pub(super) fn emit_measures( ) { let pad = " ".repeat(indent); let mut emit_state = EmitState::default(); + // The stems LilyPond draws by itself: a stem command only where the + // source's differ. + let defaults = crate::ir::beams::default_stems(part); // In relative mode the body is wrapped in `\relative REF { … }` (REF is the // part's first pitch, chosen in parts.rs). The first note's octave marks must // be computed relative to REF, not absolute — otherwise `\relative a' { a' }` @@ -108,7 +125,7 @@ pub(super) fn emit_measures( // Anacrusis: emit \partial before first measure if is_first_measure { if let Some(dur) = partial_dur { - lines.push(format!("{pad}\\partial {}", partial_to_ly(dur))); + lines.push(format!("{pad}\\partial {}", length_to_ly(dur))); } is_first_measure = false; } @@ -138,54 +155,70 @@ pub(super) fn emit_measures( } } - // Separate directions into standalone (tempo, rehearsal) and note-attached (dynamics, wedges, markup). - // Note-attached directions are grouped by their forward-position offset - // (populated in mxml_to_ir) so they attach to the correct voice element. - // (position in the bar, in divisions or exactly) → marks there. - let mut dir_parts: Vec<(i32, Frac, Vec)> = Vec::new(); + // Compute divisions for mapping direction offsets to voice element indices. + let divisions: i64 = measure + .attributes + .as_ref() + .map(|a| a.divisions as i64) + .unwrap_or(last_divisions); + last_divisions = divisions; + // Score-wide marks (tempo, rehearsal marks, D.C., breaks) go in the + // part's first staff only, a staff's own marks in that staff: a piano + // part's tempo was written into both staves and read back twice. + let first_staff = staff_filter.is_none_or(|s| s == 1); + let mut marks = Marks::default(); for dir in &measure.directions { - // Tempo and rehearsal marks can stand alone - if let Some(tempo) = &dir.tempo { - lines.push(format!("{pad}{}", tempo_to_ly(tempo))); - } - if dir.rehearsal.is_some() { - lines.push(format!("{pad}\\mark \\default")); - } - if dir.coda { - lines.push(format!( - "{pad}\\mark \\markup {{ \\musicglyph \"scripts.coda\" }}" - )); - } - if dir.segno { - lines.push(format!( - "{pad}\\mark \\markup {{ \\musicglyph \"scripts.segno\" }}" - )); - } - if let Some(text) = &dir.da_capo { - let text = super::helpers::escape_ly_string(text); - lines.push(format!("{pad}\\mark \"{text}\"")); + // Each mark at its exact place in the bar: `offset_frac` (every + // reader sets it), else the MusicXML divisions `offset`. + let at = if dir.offset_frac != Frac::from_integer(0) || dir.offset == 0 { + dir.offset_frac + } else { + Frac::new(i64::from(dir.offset), 4 * divisions.max(1)) + }; + if first_staff { + let before = marks.before.entry(at).or_default(); + if let Some(tempo) = &dir.tempo { + before.push(tempo_to_ly(tempo)); + } + // `\mark \default` where it prints the mark's text, else + // the text. + if let Some(r) = &dir.rehearsal { + before.push(super::helpers::rehearsal_to_ly( + &r.text, + &mut emit_state.marks, + )); + } + if dir.coda { + before.push("\\mark \\markup { \\musicglyph \"scripts.coda\" }".to_string()); + } + if dir.segno { + before.push("\\mark \\markup { \\musicglyph \"scripts.segno\" }".to_string()); + } + for text in dir.da_capo.iter().chain(&dir.dal_segno) { + let text = super::helpers::escape_ly_string(text); + before.push(format!("\\mark \"{text}\"")); + } + if let Some(lb) = &dir.layout_break { + before.push( + match lb { + crate::ir::direction::LayoutBreakType::System => "\\break", + crate::ir::direction::LayoutBreakType::Page => "\\pageBreak", + crate::ir::direction::LayoutBreakType::Section => "\\section", + } + .to_string(), + ); + } } - if let Some(text) = &dir.dal_segno { - let text = super::helpers::escape_ly_string(text); - lines.push(format!("{pad}\\mark \"{text}\"")); + if staff_filter.is_some_and(|sf| dir.staff.max(1) != sf) { + continue; } - if let Some(lb) = &dir.layout_break { - match lb { - crate::ir::direction::LayoutBreakType::System => { - lines.push(format!("{pad}\\break")); - } - crate::ir::direction::LayoutBreakType::Page => { - lines.push(format!("{pad}\\pageBreak")); - } - crate::ir::direction::LayoutBreakType::Section => { - lines.push(format!("{pad}\\section")); - } - } + if let Some(clef) = &dir.clef { + marks.before.entry(at).or_default().push(clef_to_ly(clef)); } // Dynamics, wedges, text, pedal, octave shifts must attach to a note let mut parts: Vec = Vec::new(); if let Some(dyn_mark) = &dir.dynamic { - parts.push(format!("\\{}", dyn_mark.sign)); + parts.push(super::maps::dynamic_to_ly(&dyn_mark.sign)); } if let Some(wedge) = &dir.wedge { let cmd = match wedge.wedge_type.as_str() { @@ -224,30 +257,10 @@ pub(super) fn emit_measures( parts.push(format!("\\ottava #{}", oct.octaves())); } } - if !parts.is_empty() { - dir_parts.push((dir.offset, dir.offset_frac, parts)); - } - } - - // Compute divisions for mapping direction offsets to voice element indices. - let divisions: i64 = measure - .attributes - .as_ref() - .map(|a| a.divisions as i64) - .unwrap_or(last_divisions); - last_divisions = divisions; - // Each mark at its exact place in the bar: `offset_frac` (every - // reader sets it), else the MusicXML divisions `offset`. - let mut dir_at_offset: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for (offset, exact, parts) in dir_parts { - let at = if exact != Frac::from_integer(0) || offset == 0 { - exact - } else { - Frac::new(i64::from(offset), 4 * divisions.max(1)) - }; - dir_at_offset.entry(at).or_default().extend(parts); + marks.after.entry(at).or_default().extend(parts); } + marks.before.retain(|_, v| !v.is_empty()); + marks.after.retain(|_, v| !v.is_empty()); let pre = lines.split_off(pre_start); // Left barline @@ -286,48 +299,54 @@ pub(super) fn emit_measures( lines.extend(pre); // Voices - let voices: Vec<&Voice> = if let Some(sf) = staff_filter { - measure - .voices - .iter() - .filter(|v| super::voice_matches_staff(v, sf)) - .filter(|v| super::voice_has_content(v)) - .collect() - } else { - measure.voices.iter().collect() - }; + let voices: Vec<(&Voice, &Voice)> = super::bar_voices(measure, staff_filter, lead) + .into_iter() + .map(|i| (&measure.voices[i], &defaults.measures[mi].voices[i])) + .collect(); if voices.len() <= 1 { - if let Some(voice) = voices.first() { + if let Some(&(voice, default)) = voices.first() { emit_voice_elements( voice, + default, + None, lang, mode, &mut emit_state, &pad, - &dir_at_offset, + &marks, lines, ); } } else { - // Multi-voice: << \\ >> syntax + // Multi-voice: `<< { } \\ { } >>`. With lyrics, the voice they + // follow goes on through the bar and the others are new voices: + // every `\\` voice is new, and lyrics skip it. lines.push(format!("{pad}<<")); - let empty_dirs = std::collections::BTreeMap::new(); - for (i, voice) in voices.iter().enumerate() { - if i > 0 { - lines.push(format!("{pad} \\\\")); + let no_marks = Marks::default(); + for (i, &(voice, default)) in voices.iter().enumerate() { + if lead.is_some() { + let cmd = ["\\voiceOne", "\\voiceTwo", "\\voiceThree", "\\voiceFour"][i.min(3)]; + let new = if i > 0 { "\\new Voice " } else { "" }; + lines.push(format!("{pad} {new}{{ {cmd}")); + } else { + if i > 0 { + lines.push(format!("{pad} \\\\")); + } + lines.push(format!("{pad} {{")); } - lines.push(format!("{pad} {{")); let inner_pad = format!("{pad} "); // Only attach directions to the first voice - let dirs_for_voice = if i == 0 { &dir_at_offset } else { &empty_dirs }; + let marks_for_voice = if i == 0 { &marks } else { &no_marks }; emit_voice_elements( voice, + default, + Some(i), lang, mode, &mut emit_state, &inner_pad, - dirs_for_voice, + marks_for_voice, lines, ); // Only a lone voice carries graces on. @@ -337,6 +356,9 @@ pub(super) fn emit_measures( lines.push(format!("{pad} }}")); } lines.push(format!("{pad}>>")); + if lead.is_some() { + lines.push(format!("{pad}\\oneVoice")); + } } // Right barline @@ -421,24 +443,48 @@ pub(super) fn emit_measures( } } +#[allow(clippy::too_many_arguments)] +/// One voice's music in a bar: `default` is the voice as LilyPond would +/// stem it, `branch` its place in a `<< \\ >>` block (`None` for the main +/// voice). #[allow(clippy::too_many_arguments)] fn emit_voice_elements( voice: &Voice, + default: &Voice, + branch: Option, lang: PitchLanguage, mode: PitchMode, state: &mut EmitState, pad: &str, - dir_at_offset: &std::collections::BTreeMap>, + marks: &Marks, lines: &mut Vec, ) { - let mut tokens: Vec = state.grace_carry.take().into_iter().collect(); + let dir_at_offset = &marks.after; + // Commands go before the note at their place (before its graces); those + // at the bar's start before graces carried from the bar before too. + let mut commands = marks.before.iter().peekable(); + let mut tokens: Vec = Vec::new(); + while let Some((_, c)) = commands.next_if(|(at, _)| **at <= Frac::from_integer(0)) { + tokens.extend(c.iter().cloned()); + } + tokens.extend(state.grace_carry.take()); let mut in_tuplet = false; let mut open_ratio: (u8, u8) = (1, 1); // Open duration-ratio tuplet (actual, normal) for elements that carry a // tuplet ratio in their Duration but no explicit TupletDisplay — see the // fallback below. let mut dur_tuplet: Option<(u8, u8)> = None; - let mut current_stem: String = String::new(); // track stem direction changes + // The stem command in force: the main voice's carries on from bar to + // bar; a `\\` voice starts at its own direction, which `reset` restores. + let mut current_stem = match branch { + None => std::mem::take(&mut state.stem), + Some(_) => String::new(), + }; + let reset = match branch { + None => "\\stemNeutral", + Some(k) if k % 2 == 0 => "\\stemUp", + Some(_) => "\\stemDown", + }; // Running position in the bar, in wholes (grace notes take none). let mut fwd_pos = Frac::from_integer(0); @@ -456,6 +502,9 @@ fn emit_voice_elements( let mut idx = 0; while idx < voice.elements.len() { let elem = &voice.elements[idx]; + while let Some((_, c)) = commands.next_if(|(at, _)| **at <= fwd_pos) { + tokens.extend(c.iter().cloned()); + } // Two-note tremolo: a start note paired with the following stop note // emits `\repeat tremolo N { a b }` (both consumed together). @@ -533,25 +582,30 @@ fn emit_voice_elements( // Collect any directions that should attach at the current position. let dir_suffix = dirs_at(fwd_pos); - // Emit stem direction change if needed - let elem_stem: &str = match elem { - VoiceElement::Note(n) => &n.stem_direction, - VoiceElement::Chord(c) => c.notes.first().map_or("", |n| &n.stem_direction), - _ => "", - }; - if !elem_stem.is_empty() && elem_stem != current_stem { - let cmd = match elem_stem { - "up" => "\\stemUp", - "down" => "\\stemDown", + // A stem command where the source's stem isn't LilyPond's own + // (grace notes keep theirs: up, or their voice's). + let graced = elem.notes().first().is_some_and(|n| n.is_grace); + if !matches!(elem, VoiceElement::Rest(_)) && !graced { + let source = crate::ir::beams::stem_of(elem); + let own = default + .elements + .get(idx) + .map_or("", crate::ir::beams::stem_of); + let want = match source { + "up" | "down" if source != own => source, _ => "", }; - if !cmd.is_empty() { - tokens.push(cmd.to_string()); - current_stem = elem_stem.to_string(); + if want != current_stem { + tokens.push( + match want { + "up" => "\\stemUp", + "down" => "\\stemDown", + _ => reset, + } + .to_string(), + ); + current_stem = want.to_string(); } - } else if elem_stem.is_empty() && !current_stem.is_empty() { - tokens.push("\\stemNeutral".to_string()); - current_stem.clear(); } // Emit \autoBeamOff / \autoBeamOn state changes @@ -661,6 +715,11 @@ fn emit_voice_elements( } } + // Commands at the bar's end (a D.C.) come after its last note. + for (_, c) in commands { + tokens.extend(c.iter().cloned()); + } + // Safety: close any unclosed tuplet (explicit or duration-ratio). if in_tuplet || dur_tuplet.is_some() { tokens.push("}".to_string()); @@ -669,6 +728,10 @@ fn emit_voice_elements( state.grace_carry = tokens.pop(); } + if branch.is_none() { + state.stem = current_stem; + } + // Group tokens into lines of ~72 chars. super::helpers::push_wrapped(&tokens, pad, lines); } @@ -712,7 +775,7 @@ fn push_grace(tokens: &mut Vec, token: String) { // Mixed kinds join the group too (it keeps the first one's command). let joined = token.split_once(' ').and_then(|(_, body)| { let last = tokens.last()?; - let (cmd, prev) = ["\\acciaccatura", "\\appoggiatura", "\\grace"] + let (cmd, prev) = ["\\slashedGrace", "\\grace"] .into_iter() .find_map(|c| Some((c, last.strip_prefix(c)?.strip_prefix(' ')?)))?; let group = match prev.strip_prefix("{ ").and_then(|p| p.strip_suffix(" }")) { @@ -753,10 +816,11 @@ fn grace_note_to_ly( // The main note is emitted separately; we emit only the grace part. return format!("\\afterGrace {{ {p}{d}{attach} }}"); } + // No slur implied: one the source has is written as a slur. let cmd = if note.grace_slash { - "\\acciaccatura" + "\\slashedGrace" } else { - "\\appoggiatura" + "\\grace" }; format!("{cmd} {p}{d}{attach}") } @@ -811,16 +875,15 @@ pub(super) fn chord_to_ly( } pub(super) fn attachments_to_ly(note: &Note) -> String { - let mut parts: Vec<&str> = Vec::new(); - let mut owned: Vec = Vec::new(); + let mut parts: Vec = Vec::new(); // Beam brackets (must come immediately after pitch+duration) // Look at level-1 beam only; `[` for begin, `]` for end for beam in ¬e.beams { if beam.number == 1 { match beam.beam_type.as_str() { - "begin" => parts.push("["), - "end" => parts.push("]"), + "begin" => parts.push("[".into()), + "end" => parts.push("]".into()), _ => {} } } @@ -829,15 +892,15 @@ pub(super) fn attachments_to_ly(note: &Note) -> String { // Ties for tie in ¬e.ties { if tie.tie_type == StartStop::Start { - parts.push("~"); + parts.push("~".into()); } } // Slurs for slur in ¬e.slurs { match slur.slur_type { - StartStop::Start => parts.push("("), - StartStop::Stop => parts.push(")"), + StartStop::Start => parts.push(placed(slur.placement, "(")), + StartStop::Stop => parts.push(")".into()), _ => {} } } @@ -846,13 +909,20 @@ pub(super) fn attachments_to_ly(note: &Note) -> String { for art in ¬e.articulations { let ly = articulation_to_ly(&art.name); if !ly.is_empty() { - parts.push(ly); + parts.push(placed(art.placement, ly)); } } - // Fermata - if note.fermata.is_some() { - parts.push("\\fermata"); + // Fermata (below: inverted) + if let Some(f) = ¬e.fermata { + parts.push( + if f.inverted { + "_\\fermata" + } else { + "\\fermata" + } + .into(), + ); } // Ornaments @@ -863,13 +933,16 @@ pub(super) fn attachments_to_ly(note: &Note) -> String { } let ly = ornament_to_ly(&orn.name); if !ly.is_empty() { - parts.push(ly); + parts.push(placed(orn.placement, ly)); } } // Dynamics (note-attached) for dyn_mark in ¬e.dynamics { - owned.push(format!("\\{}", dyn_mark.sign)); + parts.push(placed( + dyn_mark.placement, + &super::maps::dynamic_to_ly(&dyn_mark.sign), + )); } // Wedges (note-attached) @@ -881,48 +954,50 @@ pub(super) fn attachments_to_ly(note: &Note) -> String { _ => "", }; if !cmd.is_empty() { - parts.push(cmd); + parts.push(placed(wedge.placement, cmd)); } } // Glissando (the style override is emitted as a prefix in emit_voice_elements) if note.glissando == Some(StartStop::Start) || note.slide == Some(StartStop::Start) { - parts.push("\\glissando"); + parts.push("\\glissando".into()); } // Technicals (fingering, bow marks, etc.) for tech in ¬e.technicals { match tech.name.as_str() { - "fingering" => { - owned.push(format!("-{}", tech.value)); - } - "up-bow" => parts.push("\\upbow"), - "down-bow" => parts.push("\\downbow"), - "open-string" => parts.push("\\open"), - "snap-pizzicato" => parts.push("\\snappizzicato"), - "harmonic" => parts.push("\\flageolet"), - "stopped" => parts.push("-+"), - "string" => { - owned.push(format!("\\{}", tech.value)); - } + "fingering" => parts.push(format!("-{}", tech.value)), + "up-bow" => parts.push("\\upbow".into()), + "down-bow" => parts.push("\\downbow".into()), + "open-string" => parts.push("\\open".into()), + "snap-pizzicato" => parts.push("\\snappizzicato".into()), + "harmonic" => parts.push("\\flageolet".into()), + "stopped" => parts.push("-+".into()), + "string" => parts.push(format!("\\{}", tech.value)), _ => {} } } // Text written at the note (`^\markup { "dolce" }`) for td in ¬e.text_directions { - let dir = match td.placement { - crate::ir::articulation::Placement::Above => '^', - crate::ir::articulation::Placement::Below => '_', - _ => '-', - }; let text = super::helpers::escape_ly_string(&td.text); - owned.push(format!("{dir}\\markup {{ \"{text}\" }}")); + parts.push(placed(td.placement, &format!("-\\markup {{ \"{text}\" }}"))); } - let mut result: String = parts.join(""); - for o in &owned { - result.push_str(o); + parts.concat() +} + +/// A post-event with its direction: `^` above, `_` below, LilyPond's own +/// choice (`-`, or nothing) when unspecified. +fn placed(placement: crate::ir::articulation::Placement, mark: &str) -> String { + use crate::ir::articulation::Placement; + let dir = match placement { + Placement::Above => '^', + Placement::Below => '_', + Placement::Unspecified => return mark.to_string(), + }; + match mark.strip_prefix('-') { + Some(rest) => format!("{dir}{rest}"), + None => format!("{dir}{mark}"), } - result } diff --git a/src/adapters/ir_to_ly/helpers.rs b/src/adapters/ir_to_ly/helpers.rs index af785f1..9dd7179 100644 --- a/src/adapters/ir_to_ly/helpers.rs +++ b/src/adapters/ir_to_ly/helpers.rs @@ -145,3 +145,14 @@ pub(super) fn roman(n: u8) -> &'static str { _ => "X", } } + +/// A rehearsal mark: `\\mark \\default` where LilyPond's next default mark +/// (after `count` of them) prints its text, else the text. +pub(super) fn rehearsal_to_ly(text: &str, count: &mut u32) -> String { + if text == crate::ir::direction::RehearsalMark::lilypond_default(*count + 1) { + *count += 1; + "\\mark \\default".to_string() + } else { + format!("\\mark \"{}\"", escape_ly_string(text)) + } +} diff --git a/src/adapters/ir_to_ly/lyrics.rs b/src/adapters/ir_to_ly/lyrics.rs index a43a70e..d2cdf2c 100644 --- a/src/adapters/ir_to_ly/lyrics.rs +++ b/src/adapters/ir_to_ly/lyrics.rs @@ -1,262 +1,292 @@ -//! Lyrics emission. +//! Lyrics emission: one `\lyricmode` line per verse, sung on the voice that +//! carries the lyrics, one token per note it sings. The line says +//! `\set ignoreMelismata = ##t` (as musicxml2ly does): the IR already says +//! which note sings what, so LilyPond's slur, tie and beam rules must not +//! choose again. + +use std::collections::BTreeMap; use crate::ir::articulation::{LyricSyllable, StartStop, SyllabicType}; -use crate::ir::note::VoiceElement; +use crate::ir::duration::{Duration, Frac}; +use crate::ir::language::{PitchLanguage, PitchMode}; +use crate::ir::note::{Note, VoiceElement}; use crate::ir::Part; use super::helpers::{index_to_alpha, part_var_name}; +use super::maps::{length_to_ly, pitch_to_ly}; -#[derive(Debug, Clone)] -pub(super) enum LyricEvent { - Syllable(LyricSyllable), - Skip, +/// Every voice of a part that sings, as (staff, voice number), the one with +/// the most syllables first; the staff is `None` in a one-staff part. +fn lyric_voices(part: &Part) -> Vec<(Option, u8)> { + let mut count: BTreeMap<(u8, u8), usize> = BTreeMap::new(); + for v in part.measures.iter().flat_map(|m| &m.voices) { + for e in &v.elements { + if e.notes().first().is_some_and(|n| !n.lyrics.is_empty()) { + *count.entry((e.staff().max(1), v.number)).or_default() += 1; + } + } + } + let mut voices: Vec<((u8, u8), usize)> = count.into_iter().collect(); + voices.sort_by_key(|&(key, n)| (std::cmp::Reverse(n), key)); + voices + .into_iter() + .map(|((staff, voice), _)| ((part.staves > 1).then_some(staff), voice)) + .collect() } -/// Extract lyrics from a part's notes, grouped by lyric number. -/// -/// Walks notes in the same order as `attach_lyrics_to_part` in `ly_to_ir.rs`: -/// grace notes, tied continuations, `in_melisma` notes, and slur-interior notes -/// (when `no_auto_beam` is set) are automatically skipped by the voice -- no `_` -/// skip is needed in lyricmode for these. Only notes that *should* consume a -/// syllable but have no lyric attached get a `_` skip. -fn extract_lyrics( - part: &Part, - staff_filter: Option, -) -> std::collections::BTreeMap> { - let mut lyrics_by_number: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - - let mut open_slurs: u32 = 0; - - for measure in &part.measures { - for voice in &measure.voices { - for elem in &voice.elements { - // In a multi-staff part, only the lyric-bearing staff's notes - // form the syllable stream. - if let Some(sf) = staff_filter { - let staff = match elem { - VoiceElement::Note(n) => n.staff, - VoiceElement::Chord(c) => c.staff, - VoiceElement::Rest(r) => r.staff, - }; - if staff != sf { - continue; - } - } - match elem { - VoiceElement::Note(note) => { - let starts = note - .slurs - .iter() - .filter(|s| s.slur_type == StartStop::Start) - .count() as u32; - let stops = note - .slurs - .iter() - .filter(|s| s.slur_type == StartStop::Stop) - .count() as u32; +/// The voice a part's lyrics are sung on: its music carries it through +/// every bar (see `bar_voices`). Others that sing get a NullVoice. +pub(super) fn lyric_voice(part: &Part) -> Option<(Option, u8)> { + lyric_voices(part).first().copied() +} - if note.is_grace { - open_slurs = open_slurs.saturating_add(starts).saturating_sub(stops); - continue; - } +/// Whether a part has any lyrics on its notes. +pub(super) fn part_has_lyrics(part: &Part) -> bool { + lyric_voice(part).is_some() +} - let is_tied_cont = note.ties.iter().any(|t| t.tie_type == StartStop::Stop); - let in_slur_melisma = note.no_auto_beam - && open_slurs > 0 - && !note.slurs.iter().any(|s| s.slur_type == StartStop::Start); +/// For a multi-staff part, the staff whose voice sings the lyrics. +pub(super) fn lyric_staff(part: &Part) -> Option { + lyric_voice(part).and_then(|(staff, _)| staff) +} - open_slurs = open_slurs.saturating_add(starts).saturating_sub(stops); +/// The notes the lyrics' voice sings, in order: in each bar the voice the +/// music writes first (the lyrics' voice when the bar has it, see +/// `bar_voices`), without grace notes and rests. +fn sung_notes(part: &Part, staff: Option, voice: u8) -> Vec<&Note> { + part.measures + .iter() + .filter_map(|m| { + super::bar_voices(m, staff, Some(voice)) + .first() + .map(|&i| &m.voices[i].elements) + }) + .flatten() + .filter_map(|e| e.notes().first().filter(|n| !n.is_grace)) + .collect() +} - // These notes are automatically skipped -- no lyric event needed - if is_tied_cont || note.in_melisma || in_slur_melisma { - continue; - } +/// The notes of one voice on a staff, without grace notes and rests: what +/// its NullVoice sings. +fn voice_notes(part: &Part, staff: Option, voice: u8) -> Vec<&Note> { + part.measures + .iter() + .flat_map(|m| &m.voices) + .filter(|v| v.number == voice) + .flat_map(|v| &v.elements) + .filter(|e| staff.is_none_or(|s| e.staff().max(1) == s)) + .filter_map(|e| e.notes().first().filter(|n| !n.is_grace)) + .collect() +} - // This note consumes a syllable position - if !note.lyrics.is_empty() { - for syl in ¬e.lyrics { - lyrics_by_number - .entry(syl.number) - .or_default() - .push(LyricEvent::Syllable(syl.clone())); - } - } else { - // Note consumes a position but has no lyric -- emit skip - for lyrics in lyrics_by_number.values_mut() { - lyrics.push(LyricEvent::Skip); - } - } - } - VoiceElement::Chord(chord) => { - if let Some(first) = chord.notes.first() { - let starts = first - .slurs - .iter() - .filter(|s| s.slur_type == StartStop::Start) - .count() as u32; - let stops = first - .slurs - .iter() - .filter(|s| s.slur_type == StartStop::Stop) - .count() as u32; - open_slurs = open_slurs.saturating_add(starts).saturating_sub(stops); +/// Each verse (by number): its name and a token per sung note. +type Verses = BTreeMap, Vec)>; - if !first.lyrics.is_empty() { - for syl in &first.lyrics { - lyrics_by_number - .entry(syl.number) - .or_default() - .push(LyricEvent::Syllable(syl.clone())); - } - } else { - for lyrics in lyrics_by_number.values_mut() { - lyrics.push(LyricEvent::Skip); - } - } - } - } - _ => {} - } - } +fn verses(notes: &[&Note]) -> Verses { + let mut verses = Verses::new(); + for s in notes.iter().flat_map(|n| &n.lyrics) { + let name = &mut verses.entry(s.number).or_default().0; + if name.is_none() { + name.clone_from(&s.name); } } + for (&number, (_, tokens)) in verses.iter_mut() { + tokens.extend(notes.iter().map(|n| { + n.lyrics + .iter() + .find(|s| s.number == number) + .map_or_else(|| "_".to_string(), syllable_to_ly) + })); + while tokens.last().is_some_and(|t| t == "_") { + tokens.pop(); + } + } + verses +} - lyrics_by_number +/// A part's lyric lines: (the staff, the voice `\lyricsto` follows — the +/// part's own or a NullVoice's name and variable —, its verses, each with +/// its variable). +struct Line { + staff: Option, + voice: u8, + /// `None` for the part's own voice; the NullVoice's name otherwise. + null_voice: Option, + verses: Vec<(String, Option, Vec)>, } -/// Check if a part has any lyrics on its notes. -pub(super) fn part_has_lyrics(part: &Part) -> bool { - part.measures.iter().any(|m| { - m.voices.iter().any(|v| { - v.elements.iter().any(|e| match e { - VoiceElement::Note(n) => !n.lyrics.is_empty(), - VoiceElement::Chord(c) => c.notes.first().is_some_and(|n| !n.lyrics.is_empty()), - _ => false, - }) +fn lines(part: &Part) -> Vec { + let var = part_var_name(part); + lyric_voices(part) + .into_iter() + .enumerate() + .map(|(k, (staff, voice))| { + let (notes, null_voice, prefix) = if k == 0 { + (sung_notes(part, staff, voice), None, var.clone()) + } else { + let name = format!("{var}NullVoice{}", index_to_alpha(k)); + (voice_notes(part, staff, voice), Some(name.clone()), name) + }; + let verses = verses(¬es); + let count = verses.len(); + let verses = verses + .into_iter() + .map(|(number, (name, tokens))| { + let v = if count > 1 { + format!("{prefix}Verse{}", index_to_alpha(number as usize)) + } else { + format!("{prefix}Lyrics") + }; + (v, name, tokens) + }) + .collect(); + Line { + staff, + voice, + null_voice, + verses, + } }) - }) + .collect() } -/// For a multi-staff part with lyrics, the staff number whose notes carry the -/// lyrics (`\lyricsto` must target a voice on that staff). Returns `None` for -/// single-staff parts (the whole part is the lyric stream) or when there are -/// no lyrics. -pub(super) fn lyric_staff(part: &Part) -> Option { - if part.staves <= 1 { - return None; - } +/// A NullVoice: one voice's rhythm in absolute pitches, a spacer for each +/// bar it is not in (it is not printed or played; lyrics follow it). +fn null_voice_music(part: &Part, staff: Option, voice: u8, lang: PitchLanguage) -> Vec { + let mut tokens = Vec::new(); + let mut meter = Frac::from_integer(1); for m in &part.measures { - for v in &m.voices { - for e in &v.elements { - let (has, staff) = match e { - VoiceElement::Note(n) => (!n.lyrics.is_empty(), n.staff), - VoiceElement::Chord(c) => ( - c.notes.first().is_some_and(|n| !n.lyrics.is_empty()), - c.staff, - ), - _ => (false, 0), - }; - if has { - return Some(staff); - } + if let Some(ts) = m.attributes.as_ref().and_then(|a| a.time.as_ref()) { + meter = ts.beats_fraction(); + } + let len = match m.content_length() { + l if l > Frac::from_integer(0) => l, + _ => meter, + }; + let mut at = Frac::from_integer(0); + let elements = m + .voices + .iter() + .filter(|v| v.number == voice) + .flat_map(|v| &v.elements) + .filter(|e| staff.is_none_or(|s| e.staff().max(1) == s)); + for e in elements { + let d = e.metric_duration(); + if d == Frac::from_integer(0) { + continue; } + let dur = length_to_ly(&Duration::new(d)); + let pitch = |n: &Note| pitch_to_ly(&n.pitch, lang, None, PitchMode::Absolute); + let tie = |n: &Note| { + if n.ties.iter().any(|t| t.tie_type == StartStop::Start) { + "~" + } else { + "" + } + }; + tokens.push(match e { + VoiceElement::Note(n) => format!("{}{dur}{}", pitch(n), tie(n)), + VoiceElement::Chord(c) => format!( + "<{}>{dur}{}", + c.notes.iter().map(pitch).collect::>().join(" "), + c.notes.first().map_or("", tie) + ), + VoiceElement::Rest(_) => format!("s{dur}"), + }); + at += d; + } + if at < len { + tokens.push(format!("s{}", length_to_ly(&Duration::new(len - at)))); } + tokens.push("|".to_string()); } - None + tokens } -/// Emit a lyrics variable for a part. -pub(super) fn emit_lyrics_variable(part: &Part, staff_filter: Option, lines: &mut Vec) { - let lyrics_map = extract_lyrics(part, staff_filter); - if lyrics_map.is_empty() { - return; - } - - let var = part_var_name(part); - - for (&number, events) in &lyrics_map { - let suffix = if lyrics_map.len() > 1 { - format!("Verse{}", index_to_alpha(number as usize)) - } else { - "Lyrics".to_string() - }; - let lyrics_var = format!("{var}{suffix}"); - lines.push(format!("{lyrics_var} = \\lyricmode {{")); - - let mut tokens: Vec = Vec::new(); - let mut i = 0; - while i < events.len() { - match &events[i] { - LyricEvent::Skip => { - tokens.push("_".to_string()); - } - LyricEvent::Syllable(syl) => { - let text = escape_lyric_text(&syl.text); - match syl.syllabic { - SyllabicType::Begin | SyllabicType::Middle => { - tokens.push(format!("{text} --")); - } - SyllabicType::End | SyllabicType::Single => { - tokens.push(text); - } - } - if syl.extend { - tokens.push("__".to_string()); - } - } +/// Emit a part's lyric variables (one per verse) and its NullVoices. +pub(super) fn emit_lyrics_variable(part: &Part, lang: PitchLanguage, lines_out: &mut Vec) { + for line in lines(part) { + if let Some(name) = &line.null_voice { + lines_out.push(format!("{name} = {{")); + let music = null_voice_music(part, line.staff, line.voice, lang); + super::helpers::push_wrapped(&music, " ", lines_out); + lines_out.push("}".to_string()); + lines_out.push(String::new()); + } + for (var, name, tokens) in &line.verses { + lines_out.push(format!("{var} = \\lyricmode {{")); + lines_out.push(" \\set ignoreMelismata = ##t".to_string()); + if let Some(name) = name { + let name = super::helpers::escape_ly_string(name); + lines_out.push(format!(" \\set stanza = \"{name}\"")); } - i += 1; + super::helpers::push_wrapped(tokens, " ", lines_out); + lines_out.push("}".to_string()); + lines_out.push(String::new()); } + } +} - // Group tokens into lines of ~72 chars. - super::helpers::push_wrapped(&tokens, " ", lines); +/// Whether anything on this staff sings (the part's voice or a NullVoice). +pub(super) fn staff_sings(part: &Part, staff: Option) -> bool { + lyric_voices(part).iter().any(|(s, _)| *s == staff) +} - lines.push("}".to_string()); - lines.push(String::new()); +/// One syllable: `my~a` for words sung on one note, `Hal --`, `jah __`. +pub(super) fn syllable_to_ly(s: &LyricSyllable) -> String { + let words: Vec<&str> = s.text.split('\u{203F}').collect(); + let mut out = if words.len() > 1 && words.iter().all(|w| escape_lyric_text(w) == *w) { + words.join("~") + } else { + escape_lyric_text(&s.text) + }; + if matches!(s.syllabic, SyllabicType::Begin | SyllabicType::Middle) { + out.push_str(" --"); + } + if s.extend { + out.push_str(" __"); } + out } -/// Escape special characters in lyric text for LilyPond: quoted when -/// `\lyricmode` would read it as something else — a duration (`0/0/1`, -/// `2nd`), a hyphen or extender, a brace, Scheme, or several words. +/// Lyric text for `\lyricmode`, quoted when LilyPond would read it as +/// something else: a duration (any digit: `a1`, `2nd`, `dominant-11th`), a +/// hyphen or extender, a space (`_`), words on one note (`~`), a brace, a +/// comment, Scheme, or several words. pub(super) fn escape_lyric_text(text: &str) -> String { - let special = text.is_empty() - || text.starts_with(|c: char| c.is_ascii_digit()) - || matches!(text, "--" | "__" | "_") - || text.contains(|c: char| c.is_whitespace() || "\"\\{}#$".contains(c)); - if special { - format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")) - } else { + let plain = !text.is_empty() + && !matches!(text, "--" | "__" | "_") + && !text.contains(|c: char| { + c.is_ascii_digit() || c.is_whitespace() || "\"\\{}#$%~_|*=<>\u{203F}".contains(c) + }); + if plain { text.to_string() + } else { + format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")) } } -/// Emit lyrics references in the score block for a part. +/// The score block's lyrics for a staff: its NullVoices, and a +/// `\new Lyrics` for each verse sung on it (`voice_name`: the part's own +/// voice). pub(super) fn emit_lyrics_refs( part: &Part, + staff: Option, voice_name: &str, - staff_filter: Option, indent: usize, - lines: &mut Vec, + lines_out: &mut Vec, ) { - let lyrics_map = extract_lyrics(part, staff_filter); - if lyrics_map.is_empty() { - return; - } - let pad = " ".repeat(indent); - let var = part_var_name(part); - - for &number in lyrics_map.keys() { - let suffix = if lyrics_map.len() > 1 { - format!("Verse{}", index_to_alpha(number as usize)) - } else { - "Lyrics".to_string() + for line in lines(part).into_iter().filter(|l| l.staff == staff) { + let target = match &line.null_voice { + Some(name) => { + lines_out.push(format!("{pad}\\new NullVoice = \"{name}\" \\{name}")); + name.clone() + } + None => super::helpers::escape_ly_string(voice_name), }; - let lyrics_var = format!("{var}{suffix}"); - lines.push(format!( - "{pad}\\new Lyrics \\lyricsto \"{voice_name}\" \\{lyrics_var}" - )); + for (var, _, _) in &line.verses { + lines_out.push(format!("{pad}\\new Lyrics \\lyricsto \"{target}\" \\{var}")); + } } } diff --git a/src/adapters/ir_to_ly/maps.rs b/src/adapters/ir_to_ly/maps.rs index abf1751..d4717ad 100644 --- a/src/adapters/ir_to_ly/maps.rs +++ b/src/adapters/ir_to_ly/maps.rs @@ -4,7 +4,7 @@ use num::rational::Ratio; use crate::ir::duration::Duration; -use crate::ir::harmony::{ChordPitch, Figure}; +use crate::ir::harmony::Figure; use crate::ir::language::{pitch_name, PitchLanguage, PitchMode}; use crate::ir::measure::{Clef, ClefSign, KeyMode, KeySignature, TimeSignature}; use crate::ir::note::Note; @@ -74,63 +74,21 @@ pub(super) fn clef_to_ly(clef: &Clef) -> String { /// emitted `\language` (a Nederlands "fis" under `\language "english"` would /// not compile and was silently dropped on re-parse). pub(super) fn key_to_ly(key: &KeySignature, lang: PitchLanguage) -> String { - use crate::ir::pitch::PitchStep; - // Tonic as (step, alter in semitones) on the circle of fifths. - let (step, alter, mode_str): (PitchStep, i32, &str) = match key.mode { - KeyMode::Minor | KeyMode::Aeolian => { - let (step, alter) = match key.fifths { - -7 => (PitchStep::A, -1), - -6 => (PitchStep::E, -1), - -5 => (PitchStep::B, -1), - -4 => (PitchStep::F, 0), - -3 => (PitchStep::C, 0), - -2 => (PitchStep::G, 0), - -1 => (PitchStep::D, 0), - 1 => (PitchStep::E, 0), - 2 => (PitchStep::B, 0), - 3 => (PitchStep::F, 1), - 4 => (PitchStep::C, 1), - 5 => (PitchStep::G, 1), - 6 => (PitchStep::D, 1), - 7 => (PitchStep::A, 1), - _ => (PitchStep::A, 0), - }; - (step, alter, "\\minor") - } - _ => { - let mode_cmd = match key.mode { - KeyMode::Dorian => "\\dorian", - KeyMode::Phrygian => "\\phrygian", - KeyMode::Lydian => "\\lydian", - KeyMode::Mixolydian => "\\mixolydian", - KeyMode::Locrian => "\\locrian", - _ => "\\major", - }; - let (step, alter) = match key.fifths { - -7 => (PitchStep::C, -1), - -6 => (PitchStep::G, -1), - -5 => (PitchStep::D, -1), - -4 => (PitchStep::A, -1), - -3 => (PitchStep::E, -1), - -2 => (PitchStep::B, -1), - -1 => (PitchStep::F, 0), - 1 => (PitchStep::G, 0), - 2 => (PitchStep::D, 0), - 3 => (PitchStep::A, 0), - 4 => (PitchStep::E, 0), - 5 => (PitchStep::B, 0), - 6 => (PitchStep::F, 1), - 7 => (PitchStep::C, 1), - _ => (PitchStep::C, 0), - }; - (step, alter, mode_cmd) - } + let mode = match key.mode { + KeyMode::Minor | KeyMode::Aeolian => "\\minor", + KeyMode::Dorian => "\\dorian", + KeyMode::Phrygian => "\\phrygian", + KeyMode::Lydian => "\\lydian", + KeyMode::Mixolydian => "\\mixolydian", + KeyMode::Locrian => "\\locrian", + KeyMode::Major | KeyMode::Ionian => "\\major", }; + let (step, alter) = key.tonic(); let alter = Ratio::from_integer(alter); let tonic = pitch_name(step, alter, lang) .or_else(|| pitch_name(step, alter, PitchLanguage::Nederlands)) .unwrap_or_else(|| step.name().to_lowercase()); - format!("\\key {tonic} {mode_str}") + format!("\\key {tonic} {mode}") } /// Time signature -> LilyPond `\time` command. @@ -171,9 +129,10 @@ pub(super) fn duration_to_ly(dur: &Duration) -> String { format!("{base}{dots}") } -/// A `\partial` length: one written value when it is one (`2.`), else a +/// A length as one LilyPond duration (a `\partial`, a chord-mode chord): +/// one written value when it is one (`2.`), else a /// multiple of its unit (`8*5`, and `4*2/3` for a tuplet's length). -pub(super) fn partial_to_ly(dur: &Duration) -> String { +pub(super) fn length_to_ly(dur: &Duration) -> String { let len = dur.actual_duration(); if let [one] = crate::ir::notate::notate(len, None).as_slice() { if one.actual_duration() == len { @@ -280,45 +239,6 @@ pub(super) fn beat_unit_to_ly(unit: &str) -> &str { } } -/// ChordPitch -> LilyPond note name (Nederlands). -pub(super) fn chord_pitch_to_ly(cp: &ChordPitch) -> String { - let base = cp.step.to_lowercase(); - let alter = if cp.alter > 0.5 { - "is" - } else if cp.alter < -0.5 { - "es" - } else { - "" - }; - format!("{base}{alter}") -} - -/// Harmony kind -> LilyPond chordmode suffix. -pub(super) fn harmony_kind_to_ly(kind: &str) -> &str { - match kind { - "major" => "", - "minor" => ":m", - "dominant" => ":7", - "major-seventh" => ":maj7", - "minor-seventh" => ":m7", - "diminished" => ":dim", - "augmented" => ":aug", - "half-diminished" => ":m7.5-", - "diminished-seventh" => ":dim7", - "major-sixth" => ":6", - "minor-sixth" => ":m6", - "dominant-ninth" => ":9", - "major-ninth" => ":maj9", - "minor-ninth" => ":m9", - "dominant-11th" => ":11", - "dominant-13th" => ":13", - "suspended-second" => ":sus2", - "suspended-fourth" => ":sus4", - "power" => ":5", - _ => "", - } -} - /// Single figured bass figure -> LilyPond string. pub(super) fn figure_to_ly(fig: &Figure) -> String { let num = match fig.number { @@ -365,13 +285,29 @@ pub(super) fn tempo_to_ly(tempo: &crate::ir::direction::TempoDirection) -> Strin } } +/// A dynamic as LilyPond writes it: `\\pp` for the ones it defines, +/// `-#(make-dynamic-script "pppppp")` (a post-event) for any other. +pub(super) fn dynamic_to_ly(sign: &str) -> String { + // `ly/dynamic-scripts-init.ly`. + const DEFINED: [&str; 22] = [ + "ppppp", "pppp", "ppp", "pp", "p", "mp", "mf", "f", "ff", "fff", "ffff", "fffff", "fp", + "sf", "sfp", "sff", "sfz", "fz", "sp", "spp", "rfz", "n", + ]; + if DEFINED.contains(&sign) { + format!("\\{sign}") + } else { + let sign = sign.replace(['"', '\\'], ""); + format!("-#(make-dynamic-script \"{sign}\")") + } +} + #[cfg(test)] mod partial_tests { use super::*; #[test] fn partial_lengths() { - let p = |n, d| partial_to_ly(&Duration::new(Ratio::new(n, d))); + let p = |n, d| length_to_ly(&Duration::new(Ratio::new(n, d))); assert_eq!(p(1, 4), "4"); assert_eq!(p(3, 4), "2."); assert_eq!(p(5, 8), "8*5"); diff --git a/src/adapters/ir_to_ly/mod.rs b/src/adapters/ir_to_ly/mod.rs index b53a831..36f7501 100644 --- a/src/adapters/ir_to_ly/mod.rs +++ b/src/adapters/ir_to_ly/mod.rs @@ -117,9 +117,9 @@ impl FromIrAdapter for IrToLyAdapter { score.metadata.partial_duration.as_ref(), &mut lines, ); - emit_harmony_variable(part, &mut lines); + emit_harmony_variable(part, lang, &mut lines); emit_figured_bass_variable(part, &mut lines); - emit_lyrics_variable(part, lyrics::lyric_staff(part), &mut lines); + emit_lyrics_variable(part, lang, &mut lines); } // Score block @@ -307,7 +307,14 @@ fn emit_part_ref(part: &Part, indent: usize, lines: &mut Vec) { "{pad} \\new Staff = \"{} {}\" << \\new Voice = \"{voice_name}\" \\{staff_var}", part.name, staff_num )); - emit_lyrics_refs(part, &voice_name, lyric_staff, indent + 4, lines); + emit_lyrics_refs(part, Some(staff_num), &voice_name, indent + 4, lines); + lines.push(format!("{pad} >>")); + } else if lyrics::staff_sings(part, Some(staff_num)) { + lines.push(format!( + "{pad} \\new Staff = \"{} {}\" << \\{staff_var}", + part.name, staff_num + )); + emit_lyrics_refs(part, Some(staff_num), &voice_name, indent + 4, lines); lines.push(format!("{pad} >>")); } else { lines.push(format!( @@ -328,7 +335,7 @@ fn emit_part_ref(part: &Part, indent: usize, lines: &mut Vec) { lines.push(format!("{pad}\\new Staff <<")); } lines.push(format!("{pad} \\new Voice = \"{voice_name}\" \\{var}")); - emit_lyrics_refs(part, &voice_name, None, indent + 2, lines); + emit_lyrics_refs(part, None, &voice_name, indent + 2, lines); lines.push(format!("{pad}>>")); } else if !part.name.is_empty() { lines.push(format!( @@ -355,6 +362,29 @@ fn relative_is_reliable(part: &Part) -> bool { /// Returns true if the voice contains any real music content (notes, rests, chords), /// not just timing elements (forward/backup). +/// A bar's voices as the part's (or staff's) music writes them, by index: +/// those on the staff, the lyrics' voice (`lead`) first. +pub(super) fn bar_voices( + measure: &crate::ir::measure::Measure, + staff_filter: Option, + lead: Option, +) -> Vec { + let mut out: Vec = (0..measure.voices.len()) + .filter(|&i| { + let v = &measure.voices[i]; + staff_filter.is_none_or(|sf| voice_matches_staff(v, sf) && voice_has_content(v)) + }) + .collect(); + if let Some(at) = out + .iter() + .position(|&i| Some(measure.voices[i].number) == lead) + { + let first = out.remove(at); + out.insert(0, first); + } + out +} + fn voice_has_content(voice: &Voice) -> bool { voice.elements.iter().any(|e| { matches!( diff --git a/src/adapters/ir_to_ly/music_emit.rs b/src/adapters/ir_to_ly/music_emit.rs index 1d4e01e..b54dbf1 100644 --- a/src/adapters/ir_to_ly/music_emit.rs +++ b/src/adapters/ir_to_ly/music_emit.rs @@ -6,13 +6,15 @@ //! in a Score→lift round-trip. use crate::ir::annotation::Annotation; -use crate::ir::articulation::{LyricSyllable, StartStop, SyllabicType}; +use crate::ir::articulation::{Placement, StartStop}; +use crate::ir::duration::Frac; +use crate::ir::harmony::Harmony; use crate::ir::language::{PitchLanguage, PitchMode}; use crate::ir::music::{ContextType, Music, MusicDocument, RepeatType}; use crate::ir::pitch::Pitch; use super::maps::{ - articulation_to_ly, clef_to_ly, duration_to_ly, key_to_ly, ornament_to_ly, partial_to_ly, + articulation_to_ly, clef_to_ly, duration_to_ly, key_to_ly, length_to_ly, ornament_to_ly, pitch_to_ly, tempo_to_ly, time_to_ly, }; @@ -24,6 +26,10 @@ struct EmitCtx { indent: usize, /// Inside a single staff, where `<< >>` of sequential blocks means voices. in_staff: bool, + /// `\autoBeamOff` in force in the current voice. + auto_beam_off: bool, + /// `\mark \default` marks written so far. + marks: u32, } impl EmitCtx { @@ -34,6 +40,8 @@ impl EmitCtx { prev_pitch: None, indent: 0, in_staff: false, + auto_beam_off: false, + marks: 0, } } @@ -66,16 +74,64 @@ pub(super) fn emit_music_document( // overridden here.) let _ = mode; let mut ctx = EmitCtx::new(lang, PitchMode::Absolute); - match &doc.metadata.partial_duration { + let music = match &doc.metadata.partial_duration { // The opening pickup: `\partial` in the first staff. - Some(d) => emit_music(&with_pickup(&doc.music, d), &mut ctx, &mut lines), - None => emit_music(&doc.music, &mut ctx, &mut lines), + Some(d) => std::borrow::Cow::Owned(with_pickup(&doc.music, d)), + None => std::borrow::Cow::Borrowed(&doc.music), + }; + // Chord symbols go in a ChordNames line alongside the music. + let mut chords = Vec::new(); + chord_symbols(&music, Frac::from_integer(0), &mut chords); + if chords.is_empty() { + emit_music(&music, &mut ctx, &mut lines); + } else { + lines.push("<<".to_string()); + ctx.indent += 1; + lines.push(format!("{}\\new ChordNames \\chordmode {{", ctx.pad())); + let tokens = super::parts::chord_line(chords, music.written_length(), lang); + for row in tokens.chunks(8) { + lines.push(format!("{} {}", ctx.pad(), row.join(" "))); + } + lines.push(format!("{}}}", ctx.pad())); + emit_music(&music, &mut ctx, &mut lines); + ctx.indent -= 1; + lines.push(">>".to_string()); } lines.push(String::new()); lines.join("\n") } +/// The chord symbols in `m` and where they sound, from `at`, as printed +/// (repeats once). +fn chord_symbols<'a>(m: &'a Music, at: Frac, out: &mut Vec<(Frac, &'a Harmony)>) { + match m { + Music::Harmony(h) => out.push((at, h)), + Music::Sequential(items) => { + let mut t = at; + for item in items { + chord_symbols(item, t, out); + t += item.written_length(); + } + } + Music::Simultaneous(items) => items.iter().for_each(|i| chord_symbols(i, at, out)), + Music::Repeat { + body, alternatives, .. + } => { + chord_symbols(body, at, out); + let mut t = at + body.written_length(); + for alt in alternatives { + chord_symbols(alt, t, out); + t += alt.written_length(); + } + } + Music::Context { content, .. } + | Music::Variable { content, .. } + | Music::Tuplet { content, .. } => chord_symbols(content, at, out), + _ => {} + } +} + /// The music with a `\partial` opening its first staff, after the leading /// `\time`/`\key`/`\clef` (a `\time` after `\partial` would reset it). fn with_pickup(music: &Music, d: &crate::ir::duration::Duration) -> Music { @@ -138,13 +194,17 @@ fn emit_music(music: &Music, ctx: &mut EmitCtx, lines: &mut Vec) { name, content, } => { + // A new context auto-beams until told otherwise. + let saved = std::mem::take(&mut ctx.auto_beam_off); emit_context(context_type, name.as_deref(), content, ctx, lines); + ctx.auto_beam_off = saved; } Music::Note { pitch, duration, annotations, } => { + beam_mode(annotations, ctx, lines); let p = pitch_to_ly(pitch, ctx.lang, ctx.prev_pitch.as_ref(), ctx.mode); let d = duration_to_ly(duration); let a = annotations_to_ly(annotations); @@ -156,6 +216,9 @@ fn emit_music(music: &Music, ctx: &mut EmitCtx, lines: &mut Vec) { duration, annotations, } => { + if let Some((_, lead)) = pitches.first() { + beam_mode(lead, ctx, lines); + } // Ties on every note are the chord's (`~`); on some, inside // it (``). let tied = |a: &[Annotation]| a.contains(&Annotation::TieStart); @@ -192,6 +255,11 @@ fn emit_music(music: &Music, ctx: &mut EmitCtx, lines: &mut Vec) { Music::KeySignature(ks) => { lines.push(format!("{}{}", ctx.pad(), key_to_ly(ks, ctx.lang))); } + Music::Transposition(t) => { + // What a written c' sounds (absolute, outside `\relative`). + let p = pitch_to_ly(&t.sounding_c(), ctx.lang, None, PitchMode::Absolute); + lines.push(format!("{}\\transposition {p}", ctx.pad())); + } Music::Clef(clef) => { lines.push(format!("{}{}", ctx.pad(), clef_to_ly(clef))); } @@ -205,17 +273,14 @@ fn emit_music(music: &Music, ctx: &mut EmitCtx, lines: &mut Vec) { emit_barline(barline, ctx, lines); } Music::Partial(d) => { - lines.push(format!("{}\\partial {}", ctx.pad(), partial_to_ly(d))); + lines.push(format!("{}\\partial {}", ctx.pad(), length_to_ly(d))); } Music::Direction(dir) => { emit_direction(dir, ctx, lines); } Music::Grace { content, slash } => { - let cmd = if *slash { - "\\acciaccatura" - } else { - "\\appoggiatura" - }; + // No slur implied: one the source has is written as a slur. + let cmd = if *slash { "\\slashedGrace" } else { "\\grace" }; // For single-note grace, emit inline if matches!(content.as_ref(), Music::Note { .. }) { let saved_indent = ctx.indent; @@ -378,6 +443,17 @@ fn emit_sequential(children: &[Music], ctx: &mut EmitCtx, lines: &mut Vec) { + let off = annotations.contains(&Annotation::NoAutoBeam); + if off != ctx.auto_beam_off { + let cmd = if off { "\\autoBeamOff" } else { "\\autoBeamOn" }; + lines.push(format!("{}{cmd}", ctx.pad())); + ctx.auto_beam_off = off; + } +} + /// Emit a simultaneous block `<< ... >>`. fn emit_simultaneous(children: &[Music], ctx: &mut EmitCtx, lines: &mut Vec) { if children.is_empty() { @@ -394,12 +470,43 @@ fn emit_simultaneous(children: &[Music], ctx: &mut EmitCtx, lines: &mut Vec 0 { "\\new Voice " } else { "" }; + lines.push(format!("{}{new}{{ {cmd}", ctx.pad())); + ctx.indent += 1; + if k > 0 { + ctx.auto_beam_off = false; + } + for child in &group_graces(children[i].children()) { + emit_music(child, ctx, lines); + } + ctx.indent -= 1; + lines.push(format!("{}}}", ctx.pad())); + } + ctx.auto_beam_off = saved; + ctx.indent -= 1; + lines.push(format!("{}>>", ctx.pad())); + lines.push(format!("{}\\oneVoice", ctx.pad())); + return; + } for (i, child) in children.iter().enumerate() { if voices && i > 0 { lines.push(format!("{}\\\\", ctx.pad())); } + // Each `\\` voice is a new context. + if voices { + ctx.auto_beam_off = false; + } emit_music(child, ctx, lines); } + ctx.auto_beam_off = saved; ctx.indent -= 1; lines.push(format!("{}>>", ctx.pad())); } @@ -453,65 +560,95 @@ fn emit_context( } } -/// Recursively collect lyric syllables attached to notes/chords, grouped by -/// verse number, preserving order of appearance. -fn collect_lyrics(music: &Music, out: &mut std::collections::BTreeMap>) { - let push_anns = - |anns: &[Annotation], out: &mut std::collections::BTreeMap>| { - for ann in anns { - if let Annotation::Lyric(syl) = ann { - out.entry(syl.number).or_default().push(syl.clone()); - } - } - }; - match music { - Music::Note { annotations, .. } => push_anns(annotations, out), - Music::Chord { annotations, .. } => push_anns(annotations, out), - Music::Sequential(children) | Music::Simultaneous(children) => { - for c in children { - collect_lyrics(c, out); +/// A note's or chord's annotations (a chord's lyrics are on its first note). +fn lead_annotations(m: &Music) -> Option<&[Annotation]> { + match m { + Music::Note { annotations, .. } => Some(annotations), + Music::Chord { + pitches, + annotations, + .. + } => Some(match pitches.first() { + Some((_, a)) if a.iter().any(|x| matches!(x, Annotation::Lyric(_))) => a, + _ => annotations, + }), + _ => None, + } +} + +/// Whether music (not looking into other contexts) has lyrics. +fn has_lyrics(m: &Music) -> bool { + match m { + Music::Context { .. } => false, + Music::Note { .. } | Music::Chord { .. } => { + lead_annotations(m).is_some_and(|a| a.iter().any(|x| matches!(x, Annotation::Lyric(_)))) + } + other => other.children().iter().any(has_lyrics) || other.inner().is_some_and(has_lyrics), + } +} + +/// The branch of a block of voices that carries the lyrics: the first with +/// some, else the first. +fn lyric_branch(children: &[Music]) -> usize { + children.iter().position(has_lyrics).unwrap_or(0) +} + +/// The notes a context's lyrics are sung on, in order, as the music is +/// written: in a block of voices the one with lyrics; not grace notes, not +/// rests, not other contexts. +fn sung<'a>(m: &'a Music, out: &mut Vec<&'a [Annotation]>) { + match m { + Music::Note { .. } | Music::Chord { .. } => out.extend(lead_annotations(m)), + Music::Sequential(items) => items.iter().for_each(|c| sung(c, out)), + Music::Simultaneous(items) if items.iter().all(|c| matches!(c, Music::Sequential(_))) => { + if let Some(branch) = items.get(lyric_branch(items)) { + sung(branch, out); } } - // Nested contexts are a boundary: a deeper Staff/Voice emits its own - // `\addlyrics`, so we must not collect through it (avoids double-counting - // a PianoStaff's child staves). - Music::Context { .. } => {} - Music::Grace { content, .. } => collect_lyrics(content, out), - Music::Tuplet { content, .. } => collect_lyrics(content, out), + Music::Tuplet { content, .. } | Music::Variable { content, .. } => sung(content, out), Music::Repeat { body, alternatives, .. } => { - collect_lyrics(body, out); - for alt in alternatives { - collect_lyrics(alt, out); - } + sung(body, out); + alternatives.iter().for_each(|a| sung(a, out)); } _ => {} } } -/// Emit `\addlyrics { ... }` blocks for lyrics carried on a voice's notes. +/// Emit `\addlyrics { … }` for the lyrics on a context's notes: one per +/// verse, a token per sung note (`_` where the verse has no syllable), +/// melismata as the notes say (`ignoreMelismata`). fn emit_addlyrics(content: &Music, ctx: &EmitCtx, lines: &mut Vec) { - let mut by_verse = std::collections::BTreeMap::new(); - collect_lyrics(content, &mut by_verse); - if by_verse.is_empty() { - return; - } - - for syllables in by_verse.values() { - let mut tokens: Vec = Vec::new(); - for syl in syllables { - let text = super::lyrics::escape_lyric_text(&syl.text); - match syl.syllabic { - SyllabicType::Begin | SyllabicType::Middle => { - tokens.push(text); - tokens.push("--".to_string()); - } - SyllabicType::End | SyllabicType::Single => tokens.push(text), - } - if syl.extend { - tokens.push("__".to_string()); - } + let mut notes = Vec::new(); + sung(content, &mut notes); + let verses: std::collections::BTreeSet = notes + .iter() + .flat_map(|a| a.iter()) + .filter_map(|x| match x { + Annotation::Lyric(s) => Some(s.number), + _ => None, + }) + .collect(); + for verse in verses { + let mut tokens: Vec = vec!["\\set ignoreMelismata = ##t".to_string()]; + let name = notes.iter().flat_map(|a| a.iter()).find_map(|x| match x { + Annotation::Lyric(s) if s.number == verse => s.name.as_ref(), + _ => None, + }); + if let Some(name) = name { + let name = super::helpers::escape_ly_string(name); + tokens.push(format!("\\set stanza = \"{name}\"")); + } + for a in ¬es { + let syl = a.iter().find_map(|x| match x { + Annotation::Lyric(s) if s.number == verse => Some(s), + _ => None, + }); + tokens.push(syl.map_or_else(|| "_".to_string(), super::lyrics::syllable_to_ly)); + } + while tokens.last().is_some_and(|t| t == "_") { + tokens.pop(); } lines.push(format!( "{}\\addlyrics {{ {} }}", @@ -555,42 +692,79 @@ fn emit_direction( ctx: &mut EmitCtx, lines: &mut Vec, ) { - // Pedal markings - if let Some(ref pedal) = dir.pedal { - let cmd = match pedal.pedal_type.as_str() { + use super::helpers::escape_ly_string; + use crate::ir::direction::LayoutBreakType; + let pad = ctx.pad(); + // Commands, at this moment. + if let Some(r) = &dir.rehearsal { + lines.push(format!( + "{pad}{}", + super::helpers::rehearsal_to_ly(&r.text, &mut ctx.marks) + )); + } + if dir.segno { + lines.push(format!( + "{pad}\\mark \\markup {{ \\musicglyph \"scripts.segno\" }}" + )); + } + if dir.coda { + lines.push(format!( + "{pad}\\mark \\markup {{ \\musicglyph \"scripts.coda\" }}" + )); + } + for jump in dir.da_capo.iter().chain(&dir.dal_segno) { + lines.push(format!("{pad}\\mark \"{}\"", escape_ly_string(jump))); + } + if let Some(oct) = &dir.octave_shift { + if matches!(oct.shift_type.as_str(), "up" | "down" | "stop") { + lines.push(format!("{pad}\\ottava #{}", oct.octaves())); + } + } + if let Some(lb) = &dir.layout_break { + lines.push(format!( + "{pad}{}", + match lb { + LayoutBreakType::System => "\\break", + LayoutBreakType::Page => "\\pageBreak", + LayoutBreakType::Section => "\\section", + } + )); + } + // Marks on a moment rather than a note: `<>` carries them here (written + // after the note before, they were that note's). + let mut post = String::new(); + if let Some(pedal) = &dir.pedal { + post.push_str(match pedal.pedal_type.as_str() { "start" => "\\sustainOn", "stop" => "\\sustainOff", "change" => "\\sustainOff\\sustainOn", _ => "", - }; - if !cmd.is_empty() { - lines.push(format!("{}{cmd}", ctx.pad())); - } + }); } - - // Dynamic markings - if let Some(ref dyn_mark) = dir.dynamic { - lines.push(format!("{}\\{}", ctx.pad(), dyn_mark.sign)); + if let Some(dyn_mark) = &dir.dynamic { + post.push_str(&super::maps::dynamic_to_ly(&dyn_mark.sign)); } - - // Wedges - if let Some(ref wedge) = dir.wedge { - let cmd = match wedge.wedge_type.as_str() { + if let Some(wedge) = &dir.wedge { + post.push_str(match wedge.wedge_type.as_str() { "crescendo" => "\\<", "diminuendo" => "\\>", "stop" => "\\!", _ => "", + }); + } + if let Some(text) = dir.text.as_ref().filter(|t| !t.text.is_empty()) { + let at = if dir.placement == Placement::Below || text.placement == Placement::Below { + '_' + } else { + '^' }; - if !cmd.is_empty() { - lines.push(format!("{}{cmd}", ctx.pad())); - } + post.push_str(&format!( + "{at}\\markup {{ \"{}\" }}", + escape_ly_string(&text.text) + )); } - - // Text directions - if let Some(ref text) = dir.text { - if !text.text.is_empty() { - lines.push(format!("{}^\"{}\"", ctx.pad(), text.text)); - } + if !post.is_empty() { + lines.push(format!("{pad}<>{post}")); } } @@ -623,7 +797,7 @@ fn annotations_to_ly(annotations: &[Annotation]) -> String { _ => {} }, Annotation::Dynamic(dyn_mark) => { - parts.push(format!("\\{}", dyn_mark.sign)); + parts.push(super::maps::dynamic_to_ly(&dyn_mark.sign)); } Annotation::Wedge(wedge) => { let cmd = match wedge.wedge_type.as_str() { @@ -642,6 +816,7 @@ fn annotations_to_ly(annotations: &[Annotation]) -> String { Annotation::TieStop => {} // ties are implied Annotation::BeamStart => parts.push("[".to_string()), Annotation::BeamStop => parts.push("]".to_string()), + Annotation::NoAutoBeam => {} // \autoBeamOff, before the note Annotation::Fermata(_) => parts.push("\\fermata".to_string()), Annotation::Arpeggio(_) => parts.push("\\arpeggio".to_string()), Annotation::Glissando(StartStop::Start) => parts.push("\\glissando".to_string()), @@ -873,8 +1048,8 @@ mod tests { PitchMode::Absolute, ); assert!( - ly.contains("\\acciaccatura"), - "expected \\acciaccatura in: {ly}" + ly.contains("\\slashedGrace"), + "expected \\slashedGrace in: {ly}" ); } diff --git a/src/adapters/ir_to_ly/parts.rs b/src/adapters/ir_to_ly/parts.rs index 83b68ea..81594ac 100644 --- a/src/adapters/ir_to_ly/parts.rs +++ b/src/adapters/ir_to_ly/parts.rs @@ -2,83 +2,107 @@ use num::rational::Ratio; -use crate::ir::duration::Duration; +use crate::ir::duration::{Duration, Frac}; +use crate::ir::harmony::{ly_chord_modifiers, ChordPitch, Harmony}; use crate::ir::language::{pitch_name, PitchLanguage, PitchMode}; use crate::ir::note::VoiceElement; -use crate::ir::pitch::Pitch; +use crate::ir::pitch::{Alter, Pitch, PitchStep}; +use crate::ir::timeline::OFFSET_DIVISIONS; use crate::ir::Part; use super::emit::emit_measures; use super::helpers::{part_var_name, roman}; -use super::maps::{chord_pitch_to_ly, duration_to_ly, figure_to_ly, harmony_kind_to_ly}; +use super::maps::{duration_to_ly, figure_to_ly, length_to_ly}; -/// Emit a `\chordmode` variable if any measure has harmonies. -pub(super) fn emit_harmony_variable(part: &Part, lines: &mut Vec) { - let has_any = part.measures.iter().any(|m| !m.harmonies.is_empty()); - if !has_any { +/// Emit a `\chordmode` variable if any measure has harmonies: each chord at +/// its beat, lasting until the next chord or the end of its bar, written +/// `root dur :modifiers /bass` (a no-chord is a rest, which ChordNames prints +/// as N.C.). +pub(super) fn emit_harmony_variable(part: &Part, lang: PitchLanguage, lines: &mut Vec) { + if part.measures.iter().all(|m| m.harmonies.is_empty()) { return; } - let var = format!("{}Chords", part_var_name(part)); lines.push(format!("{var} = \\chordmode {{")); + for (measure, len) in part.measures.iter().zip(bar_lengths(part)) { + let per_whole = 4 * OFFSET_DIVISIONS; + let chords: Vec<(Frac, &Harmony)> = measure + .harmonies + .iter() + .map(|h| (Frac::new(h.offset.into(), per_whole), h)) + .collect(); + lines.push(format!(" {}", chord_line(chords, len, lang).join(" "))); + } + lines.push("}".to_string()); + lines.push(String::new()); +} - // Track time signature for measure durations - let mut ts_beats: i64 = 4; - let mut ts_beat_type: i64 = 4; - - for measure in &part.measures { - if let Some(attrs) = &measure.attributes { - if let Some(ts) = &attrs.time { - // Parse beats (may be compound like "3+2") - ts_beats = ts - .beats - .split('+') - .filter_map(|s| s.trim().parse::().ok()) - .sum::() - .max(1); - ts_beat_type = ts.beat_type as i64; - } +/// Chord-mode music for chords at their positions in music `len` long: a +/// spacer up to the first, each lasting until the next or the end (one of +/// two at the same place is dropped). +pub(super) fn chord_line( + mut chords: Vec<(Frac, &Harmony)>, + len: Frac, + lang: PitchLanguage, +) -> Vec { + chords.sort_by_key(|c| c.0); + let at = |c: &(Frac, &Harmony)| c.0.min(len); + let mut tokens = Vec::new(); + let first = chords.first().map_or(len, at); + if first > Frac::from_integer(0) { + tokens.push(format!("s{}", length_to_ly(&Duration::new(first)))); + } + for (k, c) in chords.iter().enumerate() { + let end = chords.get(k + 1).map_or(len, at); + if end > at(c) { + let dur = length_to_ly(&Duration::new(end - at(c))); + tokens.push(harmony_to_ly(c.1, &dur, lang)); } + } + tokens +} - if measure.harmonies.is_empty() { - // Spacer for full measure - let measure_frac = Ratio::new(ts_beats, ts_beat_type); - let dur = Duration::new(measure_frac); - lines.push(format!(" s{}", duration_to_ly(&dur))); - } else if measure.harmonies.len() == 1 { - let h = &measure.harmonies[0]; - let root = chord_pitch_to_ly(&h.root); - let kind = harmony_kind_to_ly(&h.kind); - let bass = h - .bass - .as_ref() - .map(|b| format!("/{}", chord_pitch_to_ly(b))) - .unwrap_or_default(); - let measure_frac = Ratio::new(ts_beats, ts_beat_type); - let dur = Duration::new(measure_frac); - lines.push(format!(" {root}{kind}{bass}{}", duration_to_ly(&dur))); - } else { - // Multiple harmonies: divide measure evenly - let n = measure.harmonies.len() as i64; - let each_frac = Ratio::new(ts_beats, ts_beat_type * n); - let dur = Duration::new(each_frac); - let mut tokens: Vec = Vec::new(); - for h in &measure.harmonies { - let root = chord_pitch_to_ly(&h.root); - let kind = harmony_kind_to_ly(&h.kind); - let bass = h - .bass - .as_ref() - .map(|b| format!("/{}", chord_pitch_to_ly(b))) - .unwrap_or_default(); - tokens.push(format!("{root}{kind}{bass}{}", duration_to_ly(&dur))); - } - lines.push(format!(" {}", tokens.join(" "))); - } +/// One chord-mode chord: `fis2.:m7/a`, `r4` for no chord. +fn harmony_to_ly(h: &Harmony, dur: &str, lang: PitchLanguage) -> String { + if h.kind == "none" { + return format!("r{dur}"); } + let name = |cp: &ChordPitch| { + let step = PitchStep::from_name(&cp.step).unwrap_or(PitchStep::C); + let alter = Alter::new((cp.alter * 2.0).round() as i32, 2); + pitch_name(step, alter, lang) + .or_else(|| pitch_name(step, alter, PitchLanguage::Nederlands)) + .unwrap_or_default() + }; + let modifiers = ly_chord_modifiers(&h.kind, &h.degrees); + let modifiers = if modifiers.is_empty() { + modifiers + } else { + format!(":{modifiers}") + }; + let bass = h + .bass + .as_ref() + .map(|b| format!("/{}", name(b))) + .unwrap_or_default(); + format!("{}{dur}{modifiers}{bass}", name(&h.root)) +} - lines.push("}".to_string()); - lines.push(String::new()); +/// Each bar's length as written: its music, else the meter in force. +fn bar_lengths(part: &Part) -> Vec { + let mut meter = Frac::from_integer(1); + part.measures + .iter() + .map(|m| { + if let Some(ts) = m.attributes.as_ref().and_then(|a| a.time.as_ref()) { + meter = ts.beats_fraction(); + } + match m.content_length() { + len if len > Frac::from_integer(0) => len, + _ => meter, + } + }) + .collect() } /// Emit a `\figuremode` variable if any measure has figured bass. @@ -233,11 +257,16 @@ pub(super) fn emit_part_variable( // trim trailing newline -- push as a separate line lines.push(midi_set.trim_end().to_string()); } + // The voice the lyrics follow, on the staff that has it. + let lead = super::lyrics::lyric_voice(part) + .filter(|(staff, _)| *staff == staff_filter) + .map(|(_, voice)| voice); emit_measures( part, lang, mode, staff_filter, + lead, partial_dur, relative_ref.as_ref(), 2, diff --git a/src/adapters/ir_to_ly/tests.rs b/src/adapters/ir_to_ly/tests.rs index 1f904d8..06d3eb5 100644 --- a/src/adapters/ir_to_ly/tests.rs +++ b/src/adapters/ir_to_ly/tests.rs @@ -12,7 +12,7 @@ use num::rational::Ratio; use std::collections::HashMap; use emit::{attachments_to_ly, chord_to_ly, rest_to_ly}; -use maps::{clef_to_ly, duration_to_ly, figure_to_ly, harmony_kind_to_ly, key_to_ly, pitch_to_ly}; +use maps::{clef_to_ly, duration_to_ly, figure_to_ly, key_to_ly, pitch_to_ly}; fn make_note(step: PitchStep, octave: i32, dur: Duration) -> Note { Note::new(Pitch::new(step, octave), dur) @@ -633,9 +633,10 @@ fn test_emit_acciaccatura() { score.children.push(ScoreChild::Part(part)); let adapter = IrToLyAdapter::new(); let ly = adapter.convert(&score).unwrap(); + // A slashed grace with no slur in the IR: `\\acciaccatura` would add one. assert!( - ly.contains("\\acciaccatura"), - "should emit \\acciaccatura: {}", + ly.contains("\\slashedGrace"), + "should emit \\slashedGrace: {}", ly ); } @@ -663,8 +664,8 @@ fn consecutive_graces_are_one_group() { let mut score = Score::new(); score.children.push(ScoreChild::Part(part)); let ly = IrToLyAdapter::new().convert(&score).unwrap(); - assert_eq!(ly.matches("\\acciaccatura").count(), 1, "{ly}"); - assert!(ly.contains("\\acciaccatura { e''16 f''16 }"), "{ly}"); + assert_eq!(ly.matches("\\slashedGrace").count(), 1, "{ly}"); + assert!(ly.contains("\\slashedGrace { e''16 f''16 }"), "{ly}"); } #[test] @@ -693,11 +694,9 @@ fn test_emit_grace_not_acciaccatura() { score.children.push(ScoreChild::Part(part)); let adapter = IrToLyAdapter::new(); let ly = adapter.convert(&score).unwrap(); - assert!( - ly.contains("\\appoggiatura"), - "should emit \\appoggiatura: {}", - ly - ); + // Without a slur in the IR, `\\appoggiatura` would add one. + assert!(ly.contains("\\grace e''16"), "should emit \\grace: {}", ly); + assert!(!ly.contains("\\appoggiatura"), "{ly}"); assert!( !ly.contains("\\acciaccatura"), "should NOT emit \\acciaccatura: {}", @@ -1085,7 +1084,7 @@ fn test_emit_harmony_minor_with_bass() { score.children.push(ScoreChild::Part(part)); let adapter = IrToLyAdapter::new(); let ly = adapter.convert(&score).unwrap(); - assert!(ly.contains("d:m/f"), "should emit d:m/f for Dm/F: {}", ly); + assert!(ly.contains("d1:m/f"), "should emit d1:m/f for Dm/F: {}", ly); } #[test] @@ -1141,14 +1140,52 @@ fn test_emit_figured_bass() { } #[test] -fn test_helper_harmony_kind_to_ly() { - assert_eq!(harmony_kind_to_ly("major"), ""); - assert_eq!(harmony_kind_to_ly("minor"), ":m"); - assert_eq!(harmony_kind_to_ly("dominant"), ":7"); - assert_eq!(harmony_kind_to_ly("major-seventh"), ":maj7"); - assert_eq!(harmony_kind_to_ly("diminished"), ":dim"); - assert_eq!(harmony_kind_to_ly("augmented"), ":aug"); - assert_eq!(harmony_kind_to_ly("suspended-fourth"), ":sus4"); +fn test_emit_harmony_at_its_beat() { + use crate::ir::harmony::{ChordDegree, ChordPitch, Harmony}; + let chord = |step: &str, kind: &str, degrees, offset| Harmony { + root: ChordPitch { + step: step.to_string(), + alter: 0.0, + }, + kind: kind.to_string(), + bass: None, + degrees, + offset, + function: None, + }; + let mut measure = Measure::new(1); + measure.attributes = Some(MeasureAttributes { + time: Some(TimeSignature { + beats: "3".to_string(), + beat_type: 4, + symbol: None, + }), + ..Default::default() + }); + // G7(b9) on beat 2, no chord on beat 3 (offsets in 16ths). + let flat_nine = ChordDegree { + value: 9, + alter: -1.0, + degree_type: "add".to_string(), + }; + measure + .harmonies + .push(chord("G", "dominant", vec![flat_nine], 4)); + measure.harmonies.push(chord("C", "none", vec![], 8)); + measure.voices.push(Voice { + number: 1, + elements: vec![VoiceElement::Note(Box::new(make_note( + PitchStep::G, + 4, + Duration::dotted(Ratio::new(1, 2), 1), + )))], + }); + let mut part = Part::new("P1"); + part.measures.push(measure); + let mut score = Score::new(); + score.children.push(ScoreChild::Part(part)); + let ly = IrToLyAdapter::new().convert(&score).unwrap(); + assert!(ly.contains("s4 g4:7.9- r4"), "{ly}"); } #[test] @@ -1340,6 +1377,7 @@ fn test_lyrics_emission() { number: 1, extend: false, elision: false, + name: None, }); let mut n2 = make_note(PitchStep::D, 4, Duration::quarter()); n2.lyrics.push(LyricSyllable { @@ -1348,6 +1386,7 @@ fn test_lyrics_emission() { number: 1, extend: false, elision: false, + name: None, }); let mut n3 = make_note(PitchStep::E, 4, Duration::quarter()); n3.lyrics.push(LyricSyllable { @@ -1356,6 +1395,7 @@ fn test_lyrics_emission() { number: 1, extend: false, elision: false, + name: None, }); let n4 = make_note(PitchStep::F, 4, Duration::quarter()); @@ -1473,6 +1513,7 @@ fn test_multi_staff_lyrics_are_referenced() { number: 1, extend: false, elision: false, + name: None, }); let mut n2 = make_note(PitchStep::D, 5, Duration::quarter()); n2.staff = 1; @@ -1482,6 +1523,7 @@ fn test_multi_staff_lyrics_are_referenced() { number: 1, extend: false, elision: false, + name: None, }); let mut bass = make_note(PitchStep::C, 3, Duration::half()); bass.staff = 2; @@ -1538,6 +1580,7 @@ fn test_melisma_emission() { number: 1, extend: false, elision: false, + name: None, }); let mut n2 = make_note(PitchStep::D, 4, Duration::quarter()); n2.in_melisma = true; // melisma @@ -1550,6 +1593,7 @@ fn test_melisma_emission() { number: 1, extend: false, elision: false, + name: None, }); let voice = Voice { @@ -2042,7 +2086,7 @@ fn graces_ending_a_bar_lead_into_the_next() { let score = MxmlToIrAdapter::new().convert_str(&xml).unwrap(); let ly = IrToLyAdapter::new().convert(&score).unwrap(); assert!( - ly.contains("| % 2\n \\appoggiatura { e''16 e''16 } 4"), + ly.contains("| % 2\n \\grace { e''16 e''16 } 4"), "{ly}" ); } @@ -2055,7 +2099,7 @@ fn a_one_element_tuplet_closes_before_the_next_note() { .convert_str(r"{ \time 3/4 \tuplet 4/2 { r1 } a'4 }") .unwrap(); let ly = IrToLyAdapter::new().convert(&score).unwrap(); - assert!(ly.contains(r"\tuplet 4/2 { r1 } \stemDown a'4"), "{ly}"); + assert!(ly.contains(r"\tuplet 4/2 { r1 } a'4"), "{ly}"); } #[test] diff --git a/src/adapters/ir_to_midi.rs b/src/adapters/ir_to_midi.rs index d9bde21..c34f63d 100644 --- a/src/adapters/ir_to_midi.rs +++ b/src/adapters/ir_to_midi.rs @@ -106,7 +106,26 @@ impl IrToMidiAdapter { ppq: self.divisions as i64, }; - let mut tracks: Vec> = vec![conductor(&bars, &tempo, &clock)]; + let mut first = conductor(&bars, &tempo, &clock); + // The score's title is the sequence's name (the first track's), its + // rights a copyright event; MIDI has no composer event, so a text + // event says it, as the one naming lytk does. + let md = &score.metadata; + first.extend( + md.title + .iter() + .map(|t| Timed::meta(0, Meta::TrackName(t.clone()))), + ); + first.extend( + md.composer + .iter() + .map(|c| Timed::meta(0, Meta::Text(format!("composer: {c}")))), + ); + let rights: Vec<&str> = md.rights.iter().map(|(_, r)| r.as_str()).collect(); + if !rights.is_empty() { + first.push(Timed::meta(0, Meta::Copyright(rights.join("\n")))); + } + let mut tracks: Vec> = vec![first]; let channels = assign_channels(&parts); for (pi, part) in parts.iter().enumerate() { let channel = channels[pi]; @@ -590,15 +609,23 @@ fn program_of(part: &Part) -> u8 { } /// One MIDI channel (0-based) per part. `Part.midi_channel` is 1–16 (0 = not -/// set), as in MusicXML. Percussion plays on channel 10; the others take free -/// channels around it, and past 15 parts share one with a part of the same -/// program (LilyPond's `midiChannelMapping = #'instrument`). +/// set), as in MusicXML. Percussion plays on channel 10. A part keeps its +/// source channel unless a part with another program has it first (a channel +/// has one program at a time); the others take free channels around them, +/// and past 15 parts share one with a part of the same program (LilyPond's +/// `midiChannelMapping = #'instrument`). fn assign_channels(parts: &[&Part]) -> Vec { - let mut used = [false; 16]; - used[9] = true; - for p in parts { - if (1..=16).contains(&p.midi_channel) && !is_percussion(p) { - used[p.midi_channel as usize - 1] = true; + // The program holding each channel. + let mut held: [Option; 16] = [None; 16]; + held[9] = Some(u8::MAX); + let source = |p: &Part| { + (1..=16) + .contains(&p.midi_channel) + .then(|| p.midi_channel - 1) + }; + for p in parts.iter().filter(|p| !is_percussion(p)) { + if let Some(c) = source(p) { + held[c as usize].get_or_insert(program_of(p)); } } let mut by_program: HashMap = HashMap::new(); @@ -609,10 +636,10 @@ fn assign_channels(parts: &[&Part]) -> Vec { let program = program_of(p); let ch = if is_percussion(p) { 9 - } else if (1..=16).contains(&p.midi_channel) { - p.midi_channel - 1 - } else if let Some(c) = (0..16u8).find(|&c| !used[c as usize]) { - used[c as usize] = true; + } else if let Some(c) = source(p).filter(|&c| held[c as usize] == Some(program)) { + c + } else if let Some(c) = (0..16u8).find(|&c| held[c as usize].is_none()) { + held[c as usize] = Some(program); c } else if let Some(&c) = by_program.get(&program) { c @@ -724,6 +751,18 @@ impl Player<'_> { let mut out: Vec = Vec::new(); let mut lanes: HashMap = HashMap::new(); let mut transpose = 0i32; + // A track carries one verse: the part's first. + let verse = self + .part + .measures + .iter() + .flat_map(|m| &m.voices) + .flat_map(|v| &v.elements) + .flat_map(|e| e.notes().first()) + .flat_map(|n| &n.lyrics) + .map(|l| l.number) + .min() + .unwrap_or(1); for (k, &mi) in self.bars.order.iter().enumerate() { let Some(m) = self.part.measures.get(mi) else { @@ -796,7 +835,7 @@ impl Player<'_> { } } VoiceElement::Note(n) => { - push_lyric(&mut out, clock.tick(pos), &n.lyrics); + push_lyric(&mut out, clock.tick(pos), &n.lyrics, verse); let arts: Vec<&Articulation> = n.articulations.iter().collect(); let i = self.sound( n, @@ -812,7 +851,7 @@ impl Player<'_> { } VoiceElement::Chord(c) => { if let Some(n) = c.notes.first() { - push_lyric(&mut out, clock.tick(pos), &n.lyrics); + push_lyric(&mut out, clock.tick(pos), &n.lyrics, verse); } // A chord's articulations (stored on any of its notes) // apply to all of them, as in LilyPond. @@ -947,10 +986,10 @@ impl Player<'_> { } } -/// A note's first-verse syllable as a lyric event (as LilyPond's lyric +/// A note's syllable of `verse` as a lyric event (as LilyPond's lyric /// performer writes it); a hyphenated syllable keeps its hyphen. -fn push_lyric(out: &mut Vec, tick: u64, lyrics: &[LyricSyllable]) { - let Some(l) = lyrics.iter().min_by_key(|l| l.number) else { +fn push_lyric(out: &mut Vec, tick: u64, lyrics: &[LyricSyllable], verse: u8) { + let Some(l) = lyrics.iter().find(|l| l.number == verse) else { return; }; if l.text.is_empty() { @@ -1292,6 +1331,7 @@ fn departure(dir: i8, start: f64, end: f64, lo: f64, hi: f64) -> f64 { #[derive(Clone, Debug)] enum Meta { Text(String), + Copyright(String), TrackName(String), Instrument(String), Lyric(String), @@ -1342,6 +1382,7 @@ fn encode(tracks: &[Vec], ppq: u16) -> Result> { let kind = match &t.ev { Ev::Meta(m) => TrackEventKind::Meta(match m { Meta::Text(s) => MetaMessage::Text(s.as_bytes()), + Meta::Copyright(s) => MetaMessage::Copyright(s.as_bytes()), Meta::TrackName(s) => MetaMessage::TrackName(s.as_bytes()), Meta::Instrument(s) => MetaMessage::InstrumentName(s.as_bytes()), Meta::Lyric(s) => MetaMessage::Lyric(s.as_bytes()), @@ -2041,6 +2082,41 @@ mod tests { assert_eq!(melodic[15], melodic[0]); } + #[test] + fn title_composer_and_copyright_are_written() { + use crate::adapters::midi_to_ir::MidiToIrAdapter; + let mut score = crate::adapters::ly_to_ir::LyToIrAdapter::new() + .convert_str(r"<< \new Staff { c'1 } \new Staff { e'1 } >>") + .unwrap(); + score.metadata.title = Some("Ave Maria".into()); + score.metadata.composer = Some("Schubert".into()); + score.metadata.rights = vec![("copyright".into(), "Public domain".into())]; + let bytes = IrToMidiAdapter::new().convert_bytes(&score).unwrap(); + let back = MidiToIrAdapter::new().convert_bytes(&bytes).unwrap(); + assert_eq!(back.metadata.title.as_deref(), Some("Ave Maria")); + assert_eq!(back.metadata.composer.as_deref(), Some("Schubert")); + assert_eq!( + back.metadata.rights, + [("copyright".to_string(), "Public domain".to_string())] + ); + } + + #[test] + fn parts_with_different_programs_never_share_a_channel() { + // Rossini's MuseScore file: flute and choir both on channel 1. + let part = |id: &str, ch: u8, program: u8| { + let mut p = Part::new(id); + p.midi_channel = ch; + p.midi_program = program; + p + }; + let (flute, choir, flute2) = (part("F", 1, 73), part("C", 1, 52), part("G", 1, 73)); + let channels = assign_channels(&[&flute, &choir, &flute2]); + assert_eq!(channels[0], 0); + assert_ne!(channels[1], 0, "{channels:?}"); + assert_eq!(channels[2], 0, "the same program may share it"); + } + #[test] fn transposing_instrument_sounds_at_concert_pitch() { use crate::ir::measure::Transpose; diff --git a/src/adapters/ir_to_mxml/direction.rs b/src/adapters/ir_to_mxml/direction.rs index 7ab8f5d..08a0352 100644 --- a/src/adapters/ir_to_mxml/direction.rs +++ b/src/adapters/ir_to_mxml/direction.rs @@ -398,7 +398,8 @@ impl IrToMxmlAdapter { } } - pub(super) fn build_harmony(&self, harmony: &Harmony) -> mxml::Harmony { + /// A ``, `offset` divisions after the note it is written before. + pub(super) fn build_harmony(&self, harmony: &Harmony, offset: i64) -> mxml::Harmony { // Root let root_step = mxml::RootStep { attributes: mxml::RootStepAttributes::default(), @@ -474,14 +475,10 @@ impl IrToMxmlAdapter { .collect(); // Offset - let offset = if harmony.offset != 0 { - Some(mxml::Offset { - attributes: mxml::OffsetAttributes::default(), - content: mdt::Divisions(harmony.offset), - }) - } else { - None - }; + let offset = (offset != 0).then(|| mxml::Offset { + attributes: mxml::OffsetAttributes::default(), + content: mdt::Divisions(offset as i32), + }); mxml::Harmony { attributes: mxml::HarmonyAttributes::default(), @@ -586,36 +583,7 @@ fn str_to_step(s: &str) -> mdt::Step { } fn str_to_kind_value(s: &str) -> mdt::KindValue { - match s { - "major" => mdt::KindValue::Major, - "minor" => mdt::KindValue::Minor, - "augmented" => mdt::KindValue::Augmented, - "diminished" => mdt::KindValue::Diminished, - "dominant" => mdt::KindValue::Dominant, - "major-seventh" => mdt::KindValue::MajorSeventh, - "minor-seventh" => mdt::KindValue::MinorSeventh, - "diminished-seventh" => mdt::KindValue::DiminishedSeventh, - "augmented-seventh" => mdt::KindValue::AugmentedSeventh, - "half-diminished" => mdt::KindValue::HalfDiminished, - "major-minor" => mdt::KindValue::MajorMinor, - "major-sixth" => mdt::KindValue::MajorSixth, - "minor-sixth" => mdt::KindValue::MinorSixth, - "dominant-ninth" => mdt::KindValue::DominantNinth, - "major-ninth" => mdt::KindValue::MajorNinth, - "minor-ninth" => mdt::KindValue::MinorNinth, - "dominant-11th" => mdt::KindValue::Dominant11th, - "major-11th" => mdt::KindValue::Major11th, - "minor-11th" => mdt::KindValue::Minor11th, - "dominant-13th" => mdt::KindValue::Dominant13th, - "major-13th" => mdt::KindValue::Major13th, - "minor-13th" => mdt::KindValue::Minor13th, - "suspended-second" => mdt::KindValue::SuspendedSecond, - "suspended-fourth" => mdt::KindValue::SuspendedFourth, - "power" => mdt::KindValue::Power, - "none" => mdt::KindValue::None, - "other" => mdt::KindValue::Other, - _ => mdt::KindValue::Other, - } + musicxml_internal::DatatypeDeserializer::deserialize(s).unwrap_or(mdt::KindValue::Other) } fn str_to_dynamics_type(sign: &str) -> mxml::DynamicsType { diff --git a/src/adapters/ir_to_mxml/helpers.rs b/src/adapters/ir_to_mxml/helpers.rs index c20ba04..bab1ae3 100644 --- a/src/adapters/ir_to_mxml/helpers.rs +++ b/src/adapters/ir_to_mxml/helpers.rs @@ -1,6 +1,7 @@ //! Utility functions for IR → MusicXML conversion. use crate::ir::articulation::StartStop; +use crate::ir::duration::Frac; use crate::ir::note::VoiceElement; use crate::ir::score::Score; @@ -67,7 +68,10 @@ const FALLBACK_DIVISIONS: u16 = 10080; /// the score (including tuplets and short durations), or /// [`FALLBACK_DIVISIONS`] when that number does not fit MusicXML's `u16`. pub(super) fn compute_score_divisions(score: &Score, base: u16) -> u16 { - let mut result = Some(u64::from(base.max(1))); + // A multiple of the IR's chord-symbol/figure offset unit, so those + // offsets convert exactly. + let unit = crate::ir::timeline::OFFSET_DIVISIONS as u64; + let mut result = lcm_u64(u64::from(base.max(1)), unit); for part in score.parts() { for measure in &part.measures { for voice in &measure.voices { @@ -77,18 +81,16 @@ pub(super) fn compute_score_divisions(score: &Score, base: u16) -> u16 { VoiceElement::Rest(r) => &r.duration, VoiceElement::Chord(c) => &c.duration, }; - if dur.tuplet_actual > 1 { - result = result.and_then(|r| lcm_u64(r, dur.tuplet_actual as u64)); - } - let base_n = *dur.base.numer(); - let base_d = *dur.base.denom(); - let g = gcd_u64(base_d.unsigned_abs(), (4 * base_n).unsigned_abs()); - let needed = base_d.unsigned_abs() / g.max(1); - if needed > 1 { - result = result.and_then(|r| lcm_u64(r, needed)); - } + // Every length must be a whole number of divisions: dots + // and tuplets included (a dotted 128th needs 64). + let quarters = dur.actual_duration() * Frac::from_integer(4); + result = result.and_then(|r| lcm_u64(r, quarters.denom().unsigned_abs())); } } + for d in &measure.directions { + let quarters = d.offset_frac * Frac::from_integer(4); + result = result.and_then(|r| lcm_u64(r, quarters.denom().unsigned_abs())); + } } } result diff --git a/src/adapters/ir_to_mxml/mod.rs b/src/adapters/ir_to_mxml/mod.rs index 8a70c41..97da3f8 100644 --- a/src/adapters/ir_to_mxml/mod.rs +++ b/src/adapters/ir_to_mxml/mod.rs @@ -18,6 +18,7 @@ use crate::ir::score::Score; use super::{AdapterError, FromIrAdapter, Result}; use helpers::compute_score_divisions; +use musicxml_internal::{ElementSerializer, XmlElement}; /// Default MusicXML divisions per quarter note. const DEFAULT_DIVISIONS: u16 = 4; @@ -55,7 +56,11 @@ impl IrToMxmlAdapter { version: self.version.clone(), divisions: effective_divisions, }; - adapter.build_score_partwise(score) + // MusicXML spells stems and beams out: what the source left to the + // engraver is engraved here. + let mut score = score.clone(); + crate::ir::beams::engrave(&mut score); + adapter.build_score_partwise(&score) } } @@ -99,12 +104,8 @@ impl IrToMxmlAdapter { impl FromIrAdapter for IrToMxmlAdapter { fn convert(&self, score: &Score) -> Result { let mxml_score = self.build(score); - - let bytes = musicxml::write_partwise_score_data(&mxml_score, false, false) - .map_err(AdapterError::Parse)?; - - let text = String::from_utf8(bytes).expect("MusicXML output is valid UTF-8"); - Ok(crate::adapters::decode_fractional_alters(text)) + let tree = ::serialize(&mxml_score); + Ok(crate::adapters::decode_fractional_alters(render_xml(&tree))) } fn write(&self, score: &Score, path: &Path) -> Result<()> { @@ -126,6 +127,65 @@ impl FromIrAdapter for IrToMxmlAdapter { } } +/// A score tree as MusicXML text, laid out as the `musicxml` crate lays it +/// out, every text and attribute value escaped: the crate writes them raw, +/// so a `&` in a title made XML that no parser reads. +fn render_xml(xml: &XmlElement) -> String { + let mut out = String::from("\n"); + out.push_str( + "\n", + ); + render_element(&mut out, xml, 0); + out +} + +fn render_element(out: &mut String, xml: &XmlElement, depth: usize) { + let indent = |out: &mut String| (0..depth).for_each(|_| out.push_str(" ")); + if depth > 0 { + out.push('\n'); + } + indent(out); + out.push('<'); + out.push_str(&xml.name); + for (key, value) in &xml.attributes { + out.push(' '); + out.push_str(key); + out.push_str("=\""); + escape_xml(out, value, true); + out.push('"'); + } + if xml.elements.is_empty() && xml.text.is_empty() { + out.push_str("/>"); + return; + } + out.push('>'); + for element in &xml.elements { + render_element(out, element, depth + 1); + } + if xml.text.is_empty() { + out.push('\n'); + indent(out); + } else { + escape_xml(out, &xml.text, false); + } + out.push_str("'); +} + +fn escape_xml(out: &mut String, text: &str, attribute: bool) { + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' if attribute => out.push_str("""), + c => out.push(c), + } + } +} + impl super::FromMusicAdapter for IrToMxmlAdapter { fn convert_music(&self, doc: &crate::ir::music::MusicDocument) -> Result { let score = crate::ir::lower::lower_to_score(doc); diff --git a/src/adapters/ir_to_mxml/note.rs b/src/adapters/ir_to_mxml/note.rs index de74afb..dabe55b 100644 --- a/src/adapters/ir_to_mxml/note.rs +++ b/src/adapters/ir_to_mxml/note.rs @@ -2,7 +2,7 @@ use super::helpers::{alter_to_accidental_value, start_stop_to_mxml, start_stop_to_mxml_ss}; use super::IrToMxmlAdapter; -use crate::ir::articulation::StartStop; +use crate::ir::articulation::{Placement, StartStop}; use crate::ir::note::{ArpeggioType, Chord, Note, Rest}; use crate::ir::pitch::AccidentalDisplay; @@ -527,7 +527,7 @@ fn build_note_notations(note: &Note, chord_arpeggio: Option) -> Ve id: None, line_type: None, orientation: None, - placement: None, + placement: above_below(slur.placement), relative_x: None, relative_y: None, space_length: None, @@ -571,7 +571,7 @@ fn build_note_notations(note: &Note, chord_arpeggio: Option) -> Ve let art_items: Vec = note .articulations .iter() - .filter_map(|art| str_to_articulation_type(&art.name)) + .filter_map(|art| str_to_articulation_type(&art.name, above_below(art.placement))) .collect(); if !art_items.is_empty() { items.push(mxml::NotationContentTypes::Articulations( @@ -635,7 +635,7 @@ fn build_note_notations(note: &Note, chord_arpeggio: Option) -> Ve content: (), })) } else { - str_to_ornament_type(&orn.name) + str_to_ornament_type(&orn.name, above_below(orn.placement)) } }) .collect(); @@ -797,52 +797,63 @@ fn build_fermata_notation( }) } +/// A ``: an elided syllable (`my‿a`) as its texts with `` +/// between, each with the syllabic its place in the word gives. fn build_lyric(syl: &crate::ir::articulation::LyricSyllable) -> mxml::Lyric { - let syllabic_val = match syl.syllabic { - crate::ir::articulation::SyllabicType::Single => mdt::Syllabic::Single, - crate::ir::articulation::SyllabicType::Begin => mdt::Syllabic::Begin, - crate::ir::articulation::SyllabicType::End => mdt::Syllabic::End, - crate::ir::articulation::SyllabicType::Middle => mdt::Syllabic::Middle, + use crate::ir::articulation::SyllabicType; + let syllabic = |s: SyllabicType| { + Some(mxml::Syllabic { + attributes: (), + content: match s { + SyllabicType::Single => mdt::Syllabic::Single, + SyllabicType::Begin => mdt::Syllabic::Begin, + SyllabicType::End => mdt::Syllabic::End, + SyllabicType::Middle => mdt::Syllabic::Middle, + }, + }) }; - - let mut additional = Vec::new(); - if syl.elision { - additional.push(mxml::AdditionalTextLyric { + let text = |t: &str| mxml::Text { + attributes: mxml::TextAttributes::default(), + content: t.to_string(), + }; + let words: Vec<&str> = if syl.elision { + syl.text.split('\u{203F}').collect() + } else { + vec![syl.text.as_str()] + }; + // The first word starts as the syllable does, the last ends as it does. + let starts = matches!(syl.syllabic, SyllabicType::Single | SyllabicType::Begin); + let ends = matches!(syl.syllabic, SyllabicType::Single | SyllabicType::End); + let last = words.len() - 1; + let syllabic_of = |k: usize| match (k > 0 || starts, k < last || ends) { + (true, true) => SyllabicType::Single, + (true, false) => SyllabicType::Begin, + (false, true) => SyllabicType::End, + (false, false) => SyllabicType::Middle, + }; + let additional = (1..=last) + .map(|k| mxml::AdditionalTextLyric { elision: Some(mxml::Elision { attributes: mxml::ElisionAttributes::default(), - content: String::new(), + content: "\u{203F}".to_string(), }), - syllabic: None, - text: mxml::Text { - attributes: mxml::TextAttributes::default(), - content: String::new(), - }, - }); - } - - let extend = if syl.extend { - Some(mxml::Extend { - attributes: mxml::ExtendAttributes::default(), - content: (), + syllabic: syllabic(syllabic_of(k)), + text: text(words[k]), }) - } else { - None - }; - + .collect(); + let extend = syl.extend.then(|| mxml::Extend { + attributes: mxml::ExtendAttributes::default(), + content: (), + }); mxml::Lyric { attributes: mxml::LyricAttributes { number: Some(mdt::NmToken(syl.number.to_string())), + name: syl.name.clone().map(mdt::Token), ..Default::default() }, content: mxml::LyricContents::Text(mxml::TextLyric { - syllabic: Some(mxml::Syllabic { - attributes: (), - content: syllabic_val, - }), - text: mxml::Text { - attributes: mxml::TextAttributes::default(), - content: syl.text.clone(), - }, + syllabic: syllabic(syllabic_of(0)), + text: text(words[0]), additional, extend, end_line: None, @@ -945,84 +956,141 @@ fn str_to_notehead_value(s: &str) -> Option { } } -fn str_to_articulation_type(name: &str) -> Option { +fn str_to_articulation_type( + name: &str, + placement: Option, +) -> Option { match name { "accent" => Some(mxml::ArticulationsType::Accent(mxml::Accent { - attributes: mxml::AccentAttributes::default(), + attributes: mxml::AccentAttributes { + placement, + ..Default::default() + }, content: (), })), "strong-accent" => Some(mxml::ArticulationsType::StrongAccent(mxml::StrongAccent { - attributes: mxml::StrongAccentAttributes::default(), + attributes: mxml::StrongAccentAttributes { + placement, + ..Default::default() + }, content: (), })), "staccato" => Some(mxml::ArticulationsType::Staccato(mxml::Staccato { - attributes: mxml::StaccatoAttributes::default(), + attributes: mxml::StaccatoAttributes { + placement, + ..Default::default() + }, content: (), })), "tenuto" => Some(mxml::ArticulationsType::Tenuto(mxml::Tenuto { - attributes: mxml::TenutoAttributes::default(), + attributes: mxml::TenutoAttributes { + placement, + ..Default::default() + }, content: (), })), "detached-legato" => Some(mxml::ArticulationsType::DetachedLegato( mxml::DetachedLegato { - attributes: mxml::DetachedLegatoAttributes::default(), + attributes: mxml::DetachedLegatoAttributes { + placement, + ..Default::default() + }, content: (), }, )), "staccatissimo" => Some(mxml::ArticulationsType::Staccatissimo( mxml::Staccatissimo { - attributes: mxml::StaccatissimoAttributes::default(), + attributes: mxml::StaccatissimoAttributes { + placement, + ..Default::default() + }, content: (), }, )), "spiccato" => Some(mxml::ArticulationsType::Spiccato(mxml::Spiccato { - attributes: mxml::SpiccatoAttributes::default(), + attributes: mxml::SpiccatoAttributes { + placement, + ..Default::default() + }, content: (), })), "breath-mark" => Some(mxml::ArticulationsType::BreathMark(mxml::BreathMark { - attributes: mxml::BreathMarkAttributes::default(), + attributes: mxml::BreathMarkAttributes { + placement, + ..Default::default() + }, content: mdt::BreathMarkValue::Comma, })), "caesura" => Some(mxml::ArticulationsType::Caesura(mxml::Caesura { - attributes: mxml::CaesuraAttributes::default(), + attributes: mxml::CaesuraAttributes { + placement, + ..Default::default() + }, content: mdt::CaesuraValue::Normal, })), "stress" => Some(mxml::ArticulationsType::Stress(mxml::Stress { - attributes: mxml::StressAttributes::default(), + attributes: mxml::StressAttributes { + placement, + ..Default::default() + }, content: (), })), "unstress" => Some(mxml::ArticulationsType::Unstress(mxml::Unstress { - attributes: mxml::UnstressAttributes::default(), + attributes: mxml::UnstressAttributes { + placement, + ..Default::default() + }, content: (), })), _ => None, } } -fn str_to_ornament_type(name: &str) -> Option { +fn str_to_ornament_type( + name: &str, + placement: Option, +) -> Option { match name { "trill-mark" => Some(mxml::OrnamentType::TrillMark(mxml::TrillMark { - attributes: mxml::TrillMarkAttributes::default(), + attributes: mxml::TrillMarkAttributes { + placement, + ..Default::default() + }, content: (), })), "turn" => Some(mxml::OrnamentType::Turn(mxml::Turn { - attributes: mxml::TurnAttributes::default(), + attributes: mxml::TurnAttributes { + placement, + ..Default::default() + }, content: (), })), "inverted-turn" => Some(mxml::OrnamentType::InvertedTurn(mxml::InvertedTurn { - attributes: mxml::InvertedTurnAttributes::default(), + attributes: mxml::InvertedTurnAttributes { + placement, + ..Default::default() + }, content: (), })), "mordent" => Some(mxml::OrnamentType::Mordent(mxml::Mordent { - attributes: mxml::MordentAttributes::default(), + attributes: mxml::MordentAttributes { + placement, + ..Default::default() + }, content: (), })), "inverted-mordent" => Some(mxml::OrnamentType::InvertedMordent(mxml::InvertedMordent { - attributes: mxml::InvertedMordentAttributes::default(), + attributes: mxml::InvertedMordentAttributes { + placement, + ..Default::default() + }, content: (), })), "schleifer" => Some(mxml::OrnamentType::Schleifer(mxml::Schleifer { - attributes: mxml::SchleiferAttributes::default(), + attributes: mxml::SchleiferAttributes { + placement, + ..Default::default() + }, content: (), })), _ => None, @@ -1082,3 +1150,12 @@ fn str_to_line_type(s: &str) -> Option { _ => None, } } + +/// MusicXML's `placement` for the IR's (none when unspecified). +fn above_below(p: Placement) -> Option { + match p { + Placement::Above => Some(mdt::AboveBelow::Above), + Placement::Below => Some(mdt::AboveBelow::Below), + Placement::Unspecified => None, + } +} diff --git a/src/adapters/ir_to_mxml/part.rs b/src/adapters/ir_to_mxml/part.rs index c65bea1..0e586e0 100644 --- a/src/adapters/ir_to_mxml/part.rs +++ b/src/adapters/ir_to_mxml/part.rs @@ -4,7 +4,7 @@ use super::IrToMxmlAdapter; use crate::ir::articulation::Placement; use crate::ir::direction::{BarlineType, Direction}; use crate::ir::duration::Frac; -use crate::ir::harmony::FiguredBass; +use crate::ir::harmony::{FiguredBass, Harmony}; use crate::ir::measure::{ClefSign, Measure, MeasureAttributes}; use crate::ir::note::VoiceElement; use crate::ir::voice::Voice; @@ -23,7 +23,8 @@ impl IrToMxmlAdapter { let content: Vec = part .measures .iter() - .map(|m| mxml::PartElement::Measure(self.build_measure(m, part.staves))) + .enumerate() + .map(|(i, m)| mxml::PartElement::Measure(self.build_measure(m, part.staves, i == 0))) .collect(); mxml::Part { @@ -34,7 +35,7 @@ impl IrToMxmlAdapter { } } - fn build_measure(&self, measure: &Measure, part_staves: u8) -> mxml::Measure { + fn build_measure(&self, measure: &Measure, part_staves: u8, first: bool) -> mxml::Measure { let attrs = mxml::MeasureAttributes { number: mdt::Token( measure @@ -53,7 +54,7 @@ impl IrToMxmlAdapter { width: measure.width.map(|w| mdt::Tenths(w as f64)), }; - let content = self.build_measure_elements(measure, part_staves); + let content = self.build_measure_elements(measure, part_staves, first); mxml::Measure { attributes: attrs, @@ -71,7 +72,7 @@ impl IrToMxmlAdapter { placement: Placement::Below, ..Direction::default() }; - out.push(mxml::MeasureElement::Direction(self.build_direction(&dir))); + out.push(self.direction_element(&dir)); } for wedge in &r.wedges { let dir = Direction { @@ -79,7 +80,7 @@ impl IrToMxmlAdapter { placement: Placement::Below, ..Direction::default() }; - out.push(mxml::MeasureElement::Direction(self.build_direction(&dir))); + out.push(self.direction_element(&dir)); } out } @@ -88,6 +89,7 @@ impl IrToMxmlAdapter { &self, measure: &Measure, part_staves: u8, + first: bool, ) -> Vec { let mut elements: Vec = Vec::new(); @@ -111,9 +113,12 @@ impl IrToMxmlAdapter { } } - // Attributes. A senza-misura measure emits `` - // even when it carries no other attribute change. - if measure.attributes.is_some() + // Attributes. A part's first measure always has them: `` + // must come before the first ``. A senza-misura measure + // emits `` even when it carries no other + // attribute change. + if first + || measure.attributes.is_some() || measure.senza_misura || measure.multi_measure_rest.is_some() || measure.measure_repeat.is_some() @@ -171,16 +176,25 @@ impl IrToMxmlAdapter { // Per-staff cursor into the sorted directions as we interleave them. let mut dir_idx: std::collections::BTreeMap = std::collections::BTreeMap::new(); - // Harmony / chord symbols (before notes; offset positions within measure) - for harmony in &measure.harmonies { - elements.push(mxml::MeasureElement::Harmony(self.build_harmony(harmony))); - } - - // Build an index of figured bass keyed by measure-offset (in divisions). + // Chord symbols and figures by position in this writer's divisions + // (the IR keeps their offsets in OFFSET_DIVISIONS per quarter note; + // the score's divisions are a multiple of it). + let at_divs = + |off: i32| off as i64 * self.divisions as i64 / crate::ir::timeline::OFFSET_DIVISIONS; + let mut harmonies: Vec<(i64, &Harmony)> = measure + .harmonies + .iter() + .map(|h| (at_divs(h.offset), h)) + .collect(); + harmonies.sort_by_key(|(at, _)| *at); + let mut harmony_idx = 0usize; let mut fb_by_offset: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for fb in &measure.figured_bass { - fb_by_offset.entry(fb.offset).or_default().push(fb); + fb_by_offset + .entry(at_divs(fb.offset) as i32) + .or_default() + .push(fb); } let mut fb_emitted_up_to: i32 = -1; @@ -263,6 +277,19 @@ impl IrToMxmlAdapter { } } + // Chord symbols in voice 1's stream, before the element + // sounding at their position (with `` inside it). + if vi == 0 { + let ends = fwd_pos + self.element_divisions(elem); + while harmony_idx < harmonies.len() && harmonies[harmony_idx].0 < ends { + let (at, h) = harmonies[harmony_idx]; + elements.push(mxml::MeasureElement::Harmony( + self.build_harmony(h, at - fwd_pos), + )); + harmony_idx += 1; + } + } + // Interleave this staff's measure directions right before the // note at their beat, so they anchor to this staff (and a pedal // with placement=below renders under it). @@ -270,9 +297,7 @@ impl IrToMxmlAdapter { if let Some(dirs) = dirs_by_staff.get(&vstaff) { let idx = dir_idx.entry(vstaff).or_insert(0); while *idx < dirs.len() && dirs[*idx].0 <= fwd_pos { - elements.push(mxml::MeasureElement::Direction( - self.build_direction(dirs[*idx].1), - )); + elements.push(self.direction_element(dirs[*idx].1)); *idx += 1; } } @@ -354,8 +379,14 @@ impl IrToMxmlAdapter { } } - // Emit any remaining figured bass that falls after the last note (voice 1 only) + // Chord symbols and figures after voice 1's last note. if vi == 0 { + for &(at, h) in &harmonies[harmony_idx..] { + elements.push(mxml::MeasureElement::Harmony( + self.build_harmony(h, at - fwd_pos), + )); + } + harmony_idx = harmonies.len(); for (&off, fbs) in fb_by_offset.range(fb_emitted_up_to + 1..) { for fb in fbs { elements.push(mxml::MeasureElement::FiguredBass( @@ -371,17 +402,19 @@ impl IrToMxmlAdapter { if let Some(dirs) = dirs_by_staff.get(&vstaff) { let idx = dir_idx.entry(vstaff).or_insert(0); while *idx < dirs.len() { - elements.push(mxml::MeasureElement::Direction( - self.build_direction(dirs[*idx].1), - )); + elements.push(self.direction_element(dirs[*idx].1)); *idx += 1; } } } } - // Fallback: if there are no voices at all, emit figured bass with offsets + // Fallback: if there are no voices at all, emit chord symbols and + // figured bass with offsets if voices.is_empty() { + for &(at, h) in &harmonies[harmony_idx..] { + elements.push(mxml::MeasureElement::Harmony(self.build_harmony(h, at))); + } for fbs in fb_by_offset.values() { for fb in fbs { elements.push(mxml::MeasureElement::FiguredBass( @@ -396,7 +429,7 @@ impl IrToMxmlAdapter { for (staff, dirs) in &dirs_by_staff { let start = dir_idx.get(staff).copied().unwrap_or(0); for (_, dir) in &dirs[start..] { - elements.push(mxml::MeasureElement::Direction(self.build_direction(dir))); + elements.push(self.direction_element(dir)); } } @@ -410,6 +443,19 @@ impl IrToMxmlAdapter { elements } + /// A direction as written: a clef change inside the bar is an + /// `` with the clef, anything else a ``. + fn direction_element(&self, dir: &Direction) -> mxml::MeasureElement { + match dir.clef { + Some(clef) => { + let mut attrs = MeasureAttributes::default(); + attrs.clefs.insert(dir.staff.max(1), clef); + mxml::MeasureElement::Attributes(self.build_attributes(&attrs, None, None, false)) + } + None => mxml::MeasureElement::Direction(self.build_direction(dir)), + } + } + fn build_attributes( &self, attrs: &MeasureAttributes, @@ -519,7 +565,7 @@ impl IrToMxmlAdapter { .iter() .map(|(&staff_num, c)| { let mut clef_attrs = mxml::ClefAttributes::default(); - if sorted_clefs.len() > 1 { + if sorted_clefs.len() > 1 || staff_num > 1 { clef_attrs.number = Some(mdt::StaffNumber(staff_num)); } let sign = match c.sign { @@ -764,17 +810,19 @@ impl IrToMxmlAdapter { *result.numer() / *result.denom() } - /// Calculate the total duration of a voice in divisions. + /// An element's length in divisions (a grace note takes none). + pub(super) fn element_divisions(&self, elem: &VoiceElement) -> i64 { + let d = elem.metric_duration() * Frac::from_integer(4 * self.divisions as i64); + *d.numer() / *d.denom() + } + + /// The total duration of a voice in divisions: how far `` goes + /// back after it (grace notes move no cursor). pub(super) fn voice_duration(&self, voice: &crate::ir::voice::Voice) -> i64 { - let mut total = crate::ir::duration::Frac::from_integer(0); - for elem in &voice.elements { - match elem { - VoiceElement::Note(n) => total += n.duration.actual_duration(), - VoiceElement::Rest(r) => total += r.duration.actual_duration(), - VoiceElement::Chord(c) => total += c.duration.actual_duration(), - } - } - let result = total * crate::ir::duration::Frac::from_integer(4 * self.divisions as i64); - *result.numer() / *result.denom() + voice + .elements + .iter() + .map(|e| self.element_divisions(e)) + .sum() } } diff --git a/src/adapters/ir_to_mxml/tests.rs b/src/adapters/ir_to_mxml/tests.rs index 8ce9a83..1421c0b 100644 --- a/src/adapters/ir_to_mxml/tests.rs +++ b/src/adapters/ir_to_mxml/tests.rs @@ -356,7 +356,7 @@ fn note_with_articulations() { assert!(xml.contains("")); assert!(xml.contains("")); - assert!(xml.contains("")); + assert!(xml.contains("")); } #[test] @@ -1261,7 +1261,7 @@ fn wavy_line_emission() { measure.voices.push(voice); let xml = emit_measure(measure); assert!( - xml.contains(""), + xml.contains(""), "should emit trill-mark: {xml}" ); assert!( @@ -1374,11 +1374,12 @@ fn pedal_line_attribute() { fn lyric_elision() { let mut note = Note::new(Pitch::new(PitchStep::C, 4), Duration::quarter()); note.lyrics.push(crate::ir::articulation::LyricSyllable { - text: "la".to_string(), + text: "my\u{203F}a".to_string(), syllabic: crate::ir::articulation::SyllabicType::Single, number: 1, extend: false, elision: true, + name: None, }); let voice = Voice { number: 1, @@ -1386,8 +1387,14 @@ fn lyric_elision() { }; let mut measure = make_empty_measure(); measure.voices.push(voice); - let xml = emit_measure(measure); - assert!(xml.contains(""), "should emit elision: {xml}"); + let xml: String = emit_measure(measure).split_whitespace().collect(); + // Both words, an between (it wrote an empty one and no word). + assert!( + xml.contains( + "my\u{203F}singlea" + ), + "{xml}" + ); } #[test] @@ -1761,6 +1768,7 @@ fn test_emit_lyrics() { number: 1, extend: false, elision: false, + name: None, }); part.measures[0].voices[0].elements = vec![VoiceElement::Note(Box::new(note))]; } diff --git a/src/adapters/ly_to_ir/apply.rs b/src/adapters/ly_to_ir/apply.rs index 7fc2496..7c880c7 100644 --- a/src/adapters/ly_to_ir/apply.rs +++ b/src/adapters/ly_to_ir/apply.rs @@ -14,8 +14,13 @@ pub(super) fn apply_note_attachments( note: &mut Note, attachments: &[String], ) { + // `^`/`_` before a mark places it (above, below); one mark each. + let mut placed = Placement::Unspecified; for att in attachments { + let here = std::mem::replace(&mut placed, Placement::Unspecified); match att.as_str() { + "^" => placed = Placement::Above, + "_" => placed = Placement::Below, "[" => { // Start of manual beam group _state.in_beam_group = true; @@ -47,23 +52,28 @@ pub(super) fn apply_note_attachments( }); } "(" => { + // `^(`, else `\\slurUp`'s direction + let placement = match here { + Placement::Unspecified => _state.slur_placement, + p => p, + }; note.slurs.push(SlurEvent { slur_type: StartStop::Start, number: 1, - placement: Placement::Unspecified, + placement, }); } ")" => { note.slurs.push(SlurEvent { slur_type: StartStop::Stop, number: 1, - placement: Placement::Unspecified, + placement: here, }); } "\\fermata" => { note.fermata = Some(Fermata { shape: "normal".to_string(), - inverted: false, + inverted: here == Placement::Below, }); } "\\glissando" => { @@ -80,95 +90,97 @@ pub(super) fn apply_note_attachments( "\\arpeggio" => { // Arpeggio on a single note — unusual but valid in LilyPond } - s if is_dynamic_name(s) => { - let sign = s.trim_start_matches('\\').to_string(); + // Not in an automatic beam: the source decided. + "\\noBeam" => note.no_auto_beam = true, + s if is_dynamic_name(s) || s.starts_with("dynamic:") => { + let sign = s.trim_start_matches("dynamic:").trim_start_matches('\\'); note.dynamics.push(DynamicMark { - sign, - placement: Placement::Unspecified, + sign: sign.to_string(), + placement: here, }); } "\\<" | "\\crescendo" => { note.wedges.push(Wedge { wedge_type: "crescendo".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\>" | "\\diminuendo" | "\\decrescendo" => { note.wedges.push(Wedge { wedge_type: "diminuendo".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\!" => { note.wedges.push(Wedge { wedge_type: "stop".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\trill" => { note.ornaments.push(crate::ir::articulation::Ornament { name: "trill-mark".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\mordent" => { note.ornaments.push(crate::ir::articulation::Ornament { name: "mordent".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\prall" => { note.ornaments.push(crate::ir::articulation::Ornament { name: "inverted-mordent".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\turn" => { note.ornaments.push(crate::ir::articulation::Ornament { name: "turn".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\reverseturn" => { note.ornaments.push(crate::ir::articulation::Ornament { name: "inverted-turn".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\staccato" => { note.articulations.push(Articulation { name: "staccato".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\tenuto" => { note.articulations.push(Articulation { name: "tenuto".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\accent" => { note.articulations.push(Articulation { name: "accent".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\marcato" => { note.articulations.push(Articulation { name: "strong-accent".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\staccatissimo" => { note.articulations.push(Articulation { name: "staccatissimo".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\portato" => { note.articulations.push(Articulation { name: "detached-legato".to_string(), - placement: Placement::Unspecified, + placement: here, }); } "\\stopped" => { @@ -204,7 +216,7 @@ pub(super) fn apply_note_attachments( "\\breathe" => { note.articulations.push(Articulation { name: "breath-mark".to_string(), - placement: Placement::Unspecified, + placement: here, }); } s if s.starts_with("text:") => { @@ -237,11 +249,11 @@ pub(super) fn apply_note_attachments( } pub(super) fn apply_rest_attachments(rest: &mut Rest, attachments: &[String]) { - for att in attachments { + for (k, att) in attachments.iter().enumerate() { if att == "\\fermata" { rest.fermata = Some(Fermata { shape: "normal".to_string(), - inverted: false, + inverted: k > 0 && attachments[k - 1] == "_", }); } } diff --git a/src/adapters/ly_to_ir/chord_mode.rs b/src/adapters/ly_to_ir/chord_mode.rs index 2b8a6b2..394450b 100644 --- a/src/adapters/ly_to_ir/chord_mode.rs +++ b/src/adapters/ly_to_ir/chord_mode.rs @@ -31,10 +31,17 @@ pub(super) enum HarmonyEntry { Skip(Duration), } -/// Map a LilyPond chord quality suffix (the text after `:`, e.g. `m`, `maj7`, -/// `m7.5-`) to the IR harmony kind string (shared with the ABC reader). -pub(super) fn ly_quality_to_kind(suffix: &str) -> String { - crate::ir::harmony::kind_from_suffix(suffix).to_string() +/// A LilyPond chord's modifiers (the text after `:`, e.g. `m`, `maj7`, +/// `m7.5-`, `7.9-`) as a MusicXML kind and degrees; `other` when they can't +/// be read. +pub(super) fn ly_quality(modifiers: &str) -> (String, Vec) { + match crate::ir::harmony::ly_chord_steps(modifiers) { + Some(steps) => { + let (kind, degrees) = crate::ir::harmony::kind_and_degrees(&steps); + (kind.to_string(), degrees) + } + None => ("other".to_string(), Vec::new()), + } } /// Parse a pitch-name string (e.g. `c`, `cis`, `bes`, German `h`) into a @@ -78,10 +85,14 @@ fn parse_chord_token(token: &str, lang: PitchLanguage) -> Option<(Harmony, Optio return None; } - // Split off the bass (after the first '/'), the quality (after the first ':') - // from the head (root + octave + duration). - let (main, bass_str) = match token.split_once('/') { - Some((m, b)) => (m, Some(b)), + // Split off the bass (after a '/' before a note name: `1*3/4` is a + // length), the quality (after the first ':') from the head (root + + // octave + duration). + let bass_at = token.char_indices().find(|&(i, c)| { + c == '/' && token[i + 1..].starts_with(|n: char| n.is_ascii_alphabetic() || n == '+') + }); + let (main, bass_str) = match bass_at { + Some((i, _)) => (&token[..i], Some(&token[i + 1..])), None => (token, None), }; let (head, quality) = match main.split_once(':') { @@ -119,27 +130,38 @@ fn parse_chord_token(token: &str, lang: PitchLanguage) -> Option<(Harmony, Optio parse_chord_pitch(&b[..blen], lang) }); + let (kind, degrees) = ly_quality(quality.trim()); let harmony = Harmony { root, - kind: ly_quality_to_kind(quality.trim()), + kind, bass, - degrees: Vec::new(), + degrees, offset: 0, function: None, }; Some((harmony, dur)) } -/// Parse a duration substring like `4`, `2.`, `16..` into a `Duration`. +/// Parse a duration substring like `4`, `2.`, `16..`, `1*3/4` into a `Duration`. fn parse_duration_text(s: &str) -> Option { let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect(); let val: u32 = digits.parse().ok()?; if !matches!(val, 1 | 2 | 4 | 8 | 16 | 32 | 64 | 128) { return None; } - let dots = s[digits.len()..].chars().take_while(|&c| c == '.').count() as u8; + let rest = &s[digits.len()..]; + let dots = rest.chars().take_while(|&c| c == '.').count(); let mut dur = Duration::from_lilypond_number(val, 0)?; - dur.dots = dots; + dur.dots = dots as u8; + // A scale factor `*N` or `*N/M` multiplies the length. + if let Some(factor) = rest[dots..].strip_prefix('*') { + let (n, d) = factor.split_once('/').unwrap_or((factor, "1")); + let (n, d): (i64, i64) = (n.parse().ok()?, d.parse().ok()?); + if n <= 0 || d <= 0 { + return None; + } + dur = Duration::new(dur.actual_duration() * Frac::new(n, d)); + } Some(dur) } @@ -162,9 +184,12 @@ pub(super) fn parse_chordmode_block(state: &WalkState, block: Node) -> Vec Vec Vec Harmony { + Harmony { + root: ChordPitch { + step: "C".to_string(), + alter: 0.0, + }, + kind: "none".to_string(), + bass: None, + degrees: Vec::new(), + offset: 0, + function: None, + } +} + /// Place a flat stream of harmony entries one after another from `start`. pub(super) fn place_harmonies(tl: &mut Timeline, start: Frac, entries: &[HarmonyEntry]) { let mut at = start; diff --git a/src/adapters/ly_to_ir/consume.rs b/src/adapters/ly_to_ir/consume.rs index 456dca0..76d5e27 100644 --- a/src/adapters/ly_to_ir/consume.rs +++ b/src/adapters/ly_to_ir/consume.rs @@ -5,7 +5,7 @@ use crate::ir::articulation::Placement; use crate::ir::direction::{Direction, TempoDirection}; use crate::ir::duration::{Duration, Frac}; use crate::ir::language::parse_pitch_name; -use crate::ir::note::{Chord, Note}; +use crate::ir::note::{Chord, Note, VoiceElement}; use crate::ir::pitch::{AccidentalDisplay, Pitch}; use crate::ir::score::PageLayout; @@ -292,48 +292,84 @@ pub(super) fn consume_attachments( *i += 1; } "^" | "_" | "-" => { - // Direction indicator: check what follows - if let Some(next) = children.get(*i + 1) { - if next.kind() == "escaped_word" && state.text(*next) == "\\markup" { + // A direction (`^` above, `_` below, `-` either) and + // the mark it places, which goes after a `^`/`_` + // marker the attachment reader applies to it. + let Some(&next) = children.get(*i + 1) else { + break; + }; + let placement = match ptext.as_str() { + "^" => "above", + "_" => "below", + _ => "unspecified", + }; + match next.kind() { + "escaped_word" if state.text(next) == "\\markup" => { // \markup { "text" } text direction if let Some(block) = children.get(*i + 2) { if block.kind() == "expression_block" { let text = extract_markup_text(state, *block); if !text.is_empty() { - let placement = match ptext.as_str() { - "^" => "above", - "_" => "below", - _ => "unspecified", - }; attachments.push(format!("text:{placement}:{text}")); } *i += 3; continue; } } - } else if next.kind() == "escaped_word" { + } + "escaped_word" if is_post_note_command(state.text(next)) => { // Direction + escaped command, e.g. ^\fermata - let ew = state.text(*next); - if is_post_note_command(ew) { - attachments.push(ew.to_string()); + push_placed(&mut attachments, &ptext, state.text(next).to_string()); + *i += 2; + continue; + } + "string" => { + // A text script: c^"dolce" + let text = extract_string_value(state, next); + attachments.push(format!("text:{placement}:{text}")); + *i += 2; + continue; + } + "dynamic" => { + push_placed(&mut attachments, &ptext, state.text(next).to_string()); + *i += 2; + continue; + } + "punctuation" => { + let short = punct_text(state, next); + if matches!(short.as_str(), "(" | ")" | "~" | "[" | "]") { + push_placed(&mut attachments, &ptext, short); *i += 2; continue; } - } else if next.kind() == "punctuation" { // Shorthand articulation: -. -> -_ -^ -! -+ -- - let short = punct_text(state, *next); if let Some(art) = shorthand_articulation(&short) { - attachments.push(art.to_string()); + push_placed(&mut attachments, &ptext, art.to_string()); *i += 2; continue; } - } else if next.kind() == "unsigned_integer" { + } + // A dynamic LilyPond doesn't predefine: + // `-#(make-dynamic-script "pppppp")`. + "embedded_scheme" => { + if let Some(sign) = made_dynamic(state.text(next)) { + push_placed( + &mut attachments, + &ptext, + format!("dynamic:{sign}"), + ); + *i += 2; + continue; + } + } + "unsigned_integer" => { // Fingering: -1, -2, -3, etc. - let finger = state.text(*next).to_string(); - attachments.push(format!("finger:{finger}")); + let finger = state.text(next).to_string(); + push_placed(&mut attachments, &ptext, format!("finger:{finger}")); *i += 2; continue; } + _ => {} } break; } @@ -346,12 +382,33 @@ pub(super) fn consume_attachments( attachments } +/// The sign of `#(make-dynamic-script "sign")` (a plain string only). +fn made_dynamic(scheme: &str) -> Option { + let rest = scheme.trim_start_matches('#').trim(); + let rest = rest + .strip_prefix('(')? + .trim_start() + .strip_prefix("make-dynamic-script")?; + let quoted = rest.trim().strip_suffix(')')?.trim(); + let sign = quoted.strip_prefix('"')?.strip_suffix('"')?; + (!sign.is_empty() && !sign.contains(['"', '\\'])).then(|| sign.to_string()) +} + +/// A mark with its direction: `^` or `_` first (the attachment reader places +/// the next mark by it), nothing for `-`. +fn push_placed(attachments: &mut Vec, direction: &str, mark: String) { + if direction != "-" { + attachments.push(direction.to_string()); + } + attachments.push(mark); +} + /// Map a LilyPond shorthand articulation character to its long-form escaped command. pub(super) fn shorthand_articulation(ch: &str) -> Option<&'static str> { match ch { "." => Some("\\staccato"), ">" => Some("\\accent"), - "_" => Some("\\tenuto"), + "_" => Some("\\portato"), "^" => Some("\\marcato"), "!" => Some("\\staccatissimo"), "+" => Some("\\stopped"), @@ -388,6 +445,7 @@ pub(super) fn is_post_note_command(text: &str) -> bool { | "\\flageolet" | "\\open" | "\\snappizzicato" + | "\\noBeam" ) || is_dynamic_name(text) } @@ -572,59 +630,98 @@ pub(super) fn parse_with_block( /// `\mark \markup { \musicglyph "scripts.segno" }` → segno /// `\mark "D.C."` / `\mark "D.S. al Coda"` etc. → da_capo / dal_segno pub(super) fn consume_mark(state: &mut WalkState, children: &[Node], mut i: usize) -> usize { - if i >= children.len() { + let Some(&node) = children.get(i) else { return i; - } - let node = children[i]; - if node.kind() == "string" { - // \mark "D.C." or \mark "D.S. al Coda" - let text = extract_string_value(state, node); - i += 1; - let dir = if text.starts_with("D.S.") { - Direction { - dal_segno: Some(text), - ..Default::default() - } - } else if text.starts_with("D.C.") { - Direction { - da_capo: Some(text), - ..Default::default() - } - } else { - // Generic text mark — ignore for now - return i; - }; - state.add_event(Event::direction(dir)); - } else if node.kind() == "escaped_word" && state.text(node) == "\\markup" { - // \mark \markup { ... } - i += 1; - if let Some(block) = children.get(i) { - if block.kind() == "expression_block" { - // Walk the markup block looking for \musicglyph "scripts.coda" etc. - let block_text = state.text(*block); - let dir = if block_text.contains("scripts.coda") { - Some(Direction { - coda: true, - ..Default::default() - }) - } else if block_text.contains("scripts.segno") { - Some(Direction { - segno: true, - ..Default::default() - }) - } else { - None - }; - if let Some(d) = dir { - state.add_event(Event::direction(d)); + }; + let rehearsal = |text: String| Direction { + rehearsal: Some(crate::ir::direction::RehearsalMark { text }), + ..Default::default() + }; + let dir = match node.kind() { + "escaped_word" if state.text(node) == "\\default" => { + i += 1; + state.mark_count += 1; + Some(rehearsal( + crate::ir::direction::RehearsalMark::lilypond_default(state.mark_count), + )) + } + "unsigned_integer" | "embedded_scheme" => { + // `\mark 3`, `\mark #3`: the third mark, C. + let n = state + .text(node) + .trim_start_matches(['#', '$']) + .parse::() + .ok(); + i += 1; + n.map(|n| { + state.mark_count = n; + rehearsal(crate::ir::direction::RehearsalMark::lilypond_default(n)) + }) + } + "escaped_word" if state.text(node) == "\\markup" => { + // \mark \markup { \musicglyph "scripts.coda" } is a coda sign; + // any other markup is the mark's text. + let block_text = children.get(i + 1).map(|b| state.text(*b)).unwrap_or(""); + let dir = if block_text.contains("scripts.coda") { + Some(Direction { + coda: true, + ..Default::default() + }) + } else if block_text.contains("scripts.segno") { + Some(Direction { + segno: true, + ..Default::default() + }) + } else { + mark_text(state, children, i).map(rehearsal) + }; + i = skip_markup(state, children, i + 1); + dir + } + "string" => { + // \mark "D.C." or \mark "D.S. al Coda", else a text mark + let text = extract_string_value(state, node); + i += 1; + Some(if text.starts_with("D.S.") { + Direction { + dal_segno: Some(text), + ..Default::default() } - i += 1; - } + } else if text.starts_with("D.C.") { + Direction { + da_capo: Some(text), + ..Default::default() + } + } else { + rehearsal(text) + }) } + _ => None, + }; + if let Some(d) = dir { + state.add_event(Event::direction(d)); } i } +/// The text of a string or `\markup` at `children[i]`. +pub(super) fn mark_text(state: &WalkState, children: &[Node], i: usize) -> Option { + let node = children.get(i)?; + match node.kind() { + "string" => Some(extract_string_value(state, *node)), + "escaped_word" if state.text(*node) == "\\markup" => { + let block = children.get(i + 1)?; + let text = if block.kind() == "expression_block" { + extract_markup_text(state, *block) + } else { + extract_string_value(state, *block) + }; + (!text.is_empty()).then_some(text) + } + _ => None, + } +} + /// Consume a `\override` command. /// Recognises `Glissando.style = #'