Hi,

Le 20/11/2022 à 21:50, Kieren MacMillan a écrit :
Kieren, you've got to try this out! It will blow your mind!
I just took Jean’s example, and compared the output with and without the 
\include step… Truly astonishing.
(I haven’t tried it with my Real World Scores™, but can’t wait to do so!)

Jean, seriously, this is amazing!
Agreed.

This has turned a so-so day into an amazing one!




Well, I didn't expect such success :)

Attached is a revised version:

- Stanza numbers now properly avoid the respaced lyric words
  just like they avoid lyric words by default (the problem was
  a consequence of what I said about this snippet breaking
  assumptions LilyPond makes; in this case it can be arranged),

- There is a \doReserveSpace command doing what Kieren requested,

- #(ly:set-option 'compile-scheme-code) is commented out for
  the time being,

- Added some comments.

If this looks fine to you, I'll upload it to LSR.

Best,
Jean

\version "2.23.81"

%{

Snippet author: Jean Abou Samra <j...@abou-samra.fr>
Original thread: 
https://lists.gnu.org/archive/html/lilypond-user/2022-11/msg00087.html

This snippet gets rid of uglinesses in note spacing caused by lyrics.
By default, LilyPond always puts a lyric word exactly centered under
the note it attaches to. When there is a long lyric word, LilyPond reserves
space between notes so that there will be no collisions in the lyrics.
However, this can lead to uneven note spacing. This snippet completely
removes the presence of lyrics in note spacing so that it is natural
according to the note lengths, and uses a spacing algorithm that shifts
lyrics automatically in order to avoid collisions.

Some technical comments follow.

The spacing problem is set up as a quadratic optimization problem. Each
lyric word has a strength value (by default, all words have a strength
of 1.0). The demerit associated to a lyric word is s(x-p)², where s
is the strength, x is the X coordinate and p is the ideal X coordinate
where the lyric word would be centered on its associated note. An
acceptable solution is a solution where no lyric words collide. The
weight of a solution is the sum of the demerits for each of the words.
Solving the lyric spacing problem means finding an acceptable solution
of minimal weight.

In practice, words should not touch each other, but maintain a minimum
distance between each other (controlled by LyricSpace.minimum-distance
and LyricHyphen.minimum-distance). This is reduced to the form above
by widening one of the two words for each LyricSpace or LyricHyphen
grob, by the amount given by the minimum-space property.

The algorithm to solve the lyric spacing problem uses dynamic programming
and runs in linear time. We add words one by one from left to right. After
adding each word, the problem given by the words added so far is solved.
The base case (zero words) is trivial. To add a word, it is very intuitive,
and not hard to prove, that the following technique works: if adding
the word at its optimal position produces no collision, then keep it
there; else, make this word 'push' on its left neighbor and move these
two words simultaneously to the left until the optimal position for
these two words together is reached; if this still produces a collision
then add the third word and consider the three words stuck together, etc.
Note that once two words have been stuck together, they won't need
to be taken apart again: they will be adjacent ("stuck") in the final
configuration.

Written in this form, this algorithm looks quadratic. While probably
acceptable in usual scores, this might become a problem with 
ly:one-line-breaking.
However, with a bit of simple algebra, you can see that optimizing for two
words stuck together (and, by extension, any finite number of words stuck
together) is equivalent to optimizing for one single (imaginary) combined
word, of which the length is the sum of the two lengths, the strength is
the sum of the strengths, and the optimal coordinate is given by a simple
formula (see the code). Therefore, instead of simultaneously considering
two words stuck together, you can replace them with just one fresh problem
variable. At each word added during the algorithm, there is a constant 
processing
overhead, plus an overhead linear in the number of times a word is newly
stuck to a group, forming a new group. If you imagine that all words start
out black, and every word becomes white as soon as its group is stuck to
the group on the left, it is clear that the total number of "add to group"
operations is linear in the number of words. At the end, there is a
step to compute the offset of each word from that of its group, which
is made linear by caching the offset of a group as soon as it is
visited. In this way, the total number of operations is linear.

%}

% #(ly:set-option 'compile-scheme-code)

#(use-modules (ice-9 match)
              (ice-9 hash-table)
              (oop goops))

%% convenience stuff:

#(define-syntax-rule (transform! lval proc)
   (set! lval (proc lval)))

#(define ->
   (make-procedure-with-setter
    (lambda (instance . path)
      (let loop ((instance instance) (path path))
        (match path
          ((slot)
           (slot-ref instance slot))
          ((slot . rest)
           (loop (slot-ref instance slot)
                 rest)))))
    (lambda (instance . args)
      (let loop ((instance instance) (args args))
        (match args
         ((slot new)
          (slot-set! instance slot new))
         ((slot . rest)
          (loop (slot-ref instance slot)
                rest)))))))



#(define-class <lyric-variable> ()
   (ideal #:init-keyword #:ideal)
   (extent #:init-keyword #:extent)
   (strength #:init-keyword #:strength)
   (tied-to #:init-value #f)
   (tied-offset #:init-value #f)
   (final #:init-value #f))

#(define (merged-variable! group var)
   (let* ((delta (- (interval-end (-> group 'extent))
                    (interval-start (-> var 'extent))))
          (new
           (make <lyric-variable>
                 #:ideal (/ (+ (* (-> group 'strength)
                                  (-> group 'ideal))
                               (* (-> var 'strength)
                                  (- (-> var 'ideal)
                                     delta)))
                            (+ (-> group 'strength)
                               (-> var 'strength)))
                 #:extent (cons (interval-start (-> group 'extent))
                                (+ (interval-end (-> group 'extent))
                                   (interval-length (-> var 'extent))))
                 #:strength (+ (-> group 'strength)
                               (-> var 'strength)))))
     (set! (-> group 'tied-to) new)
     (set! (-> group 'tied-offset) 0)
     (set! (-> var 'tied-to) new)
     (set! (-> var 'tied-offset) delta)
     new))

#(define (propagate! variables)
   (match variables
     ((var)
      variables)
     ((var group . rest)
      (let ((have-overlap (<= (+ (-> var 'ideal)
                                 (interval-start (-> var 'extent)))
                              (+ (-> group 'ideal)
                                 (interval-end (-> group 'extent))))))
        (if have-overlap
            (let ((merged (merged-variable! group var)))
              (propagate! (cons merged rest)))
            variables)))))

#(define (finalize! variables)
   (define (finalize-one! var)
     (unless (-> var 'final)
       (set! (-> var 'final)
             (if (-> var 'tied-to)
                 (begin
                  (finalize-one! (-> var 'tied-to))
                  (+ (-> var 'tied-to 'final)
                     (-> var 'tied-offset)))
                 (-> var 'ideal)))))
   (for-each finalize-one! variables))

#(define (solve-lyric-spacing-problem! variables)
   (fold
    (lambda (var groups)
      (propagate! (cons var groups)))
    '()
    variables)
   (finalize! variables))

#(define (respace-lyrics! grob)
   (let ((elt-array (ly:grob-object grob 'elements #f)))
     (when elt-array
       (let* ((elts (ly:grob-array->list elt-array))
              (refp (ly:grob-system grob))
              (with-iface (lambda (iface)
                            (filter (lambda (g)
                                      (grob::has-interface g iface))
                                    elts)))
              (words (filter (lambda (word)
                               (interval-sane? (ly:grob-extent word word X)))
                             (with-iface 'lyric-syllable-interface)))
              ;; Includes both LyricHyphen and LyricSpace
              (constraints (with-iface 'lyric-hyphen-interface))
              (variables (map
                          (lambda (word)
                            (let* ((xalign (ly:grob-property word 
'self-alignment-X))
                                   (coord (ly:grob-relative-coordinate word 
refp X))
                                   (orig-ext (ly:grob-extent word word X))
                                   (align-point (interval-index orig-ext 
xalign))
                                   (ideal (+ coord align-point))
                                   (extent (coord-translate orig-ext (- 
align-point)))
                                   (strength (or (assq-ref (ly:grob-property 
word 'details)
                                                           'strength)
                                                 1.0)))
                              (make <lyric-variable>
                                    #:ideal ideal
                                    #:extent extent
                                    #:strength strength)))
                          words))
              (word-to-variable (alist->hashq-table (map cons words 
variables))))
         (for-each
          (lambda (constraint)
            (let ((added (ly:grob-property constraint 'minimum-distance))
                  (left-var (hashq-ref word-to-variable (ly:spanner-bound 
constraint LEFT))))
              (when left-var
                (transform! (-> left-var 'extent)
                            (lambda (e) (cons (interval-start e)
                                              (+ (interval-end e) added)))))))
          constraints)
         (solve-lyric-spacing-problem! variables)
         (for-each
          (lambda (word variable)
            (let* ((xalign (ly:grob-property word 'self-alignment-X))
                   (orig-ext (ly:grob-extent word word X))
                   (align-point (interval-index orig-ext xalign)))
              (ly:grob-translate-axis!
               word
               (- (-> variable 'final)
                  (ly:grob-relative-coordinate word refp X)
                  align-point)
               X)))
          words
          variables)))))

% A StanzaNumber is side-positioned against lyric words.
% Its X-offset will be computed before line breaking, with
% default offset values, then these values will change, so
% we have to update it.
#(define (recompute-offset-with-moved-lyrics! grob)
   (let ((support (ly:grob-object grob 'side-support-elements #f)))
     (when support
       ;; Make sure each LyricText in the support goes to its
       ;; updated location.
       (for-each
        (lambda (word)
          (let ((axis-group (ly:grob-parent word Y)))
            (ly:grob-property axis-group 'after-line-breaking)))
        (ly:grob-array->list support))
       (let* ((parent (ly:grob-parent grob X))
              (coord (ly:grob-relative-coordinate grob parent X)))
         (ly:grob-translate-axis!
          grob
          (- (ly:side-position-interface::x-aligned-side grob)
             coord)
          X)))))

\layout {
  \context {
    \Lyrics
    \override LyricText.extra-spacing-width = #'(+inf.0 . -inf.0)
    \override LyricSpace.springs-and-rods = ##f
    \override LyricHyphen.springs-and-rods = ##f
    \override VerticalAxisGroup.after-line-breaking = #respace-lyrics!
    \override StanzaNumber.after-line-breaking = 
#recompute-offset-with-moved-lyrics!
  }
}

doReserveSpace =
\once {
  \revert LyricSpace.springs-and-rods
  \revert LyricHyphen.springs-and-rods
}

\version "2.23.81"

\include "respace-lyrics.ily"

\language "english"

struct = {
  \numericTimeSignature
  \key bf \major
  \time 3/4
  s2.*5 \break
  s2.*5 \break
}

nb = \markup { \small \italic "n.b." }

melody = \relative {
  \clef treble
  \dynamicUp
  R2.*4 |
  d'8\mp ef f4. d8 |
 
  d4 \once \phrasingSlurDashed c2_\(^\nb |
  d8\) ef f4 bf, |
  c2. |
  d8\< ef f4. f8 |
  g8 a bf2\mf |
}

pianoRH = \relative {
  \clef treble
  d''8 ef f4 <f,c'>8 f' |
  << { g8 a bf2 } \\ { <bf, d>2. } >> |
  << { ef4 c8 d ef bf } \\ { g2 g4 } >> |
  << { d'4. ef8 c4 } \\ { <ef, gf>2. } >> |
  <c' f>4 <f, d'>2 |
 
  << { d'4 c } \\ { <ef, gf>2 } >>  ef'8 c' |
  <d, bf'>4 <bf f'>2 |
  <gf d'>4 c ef8 c' |
  <d, bf'>4 f c8 f, |
  << { d'2. } \\ { bf8 a g2 } >> |
}

pianoLH = \relative {
  \clef treble
  bf8 f' ~ f4 a, |
  g8 d' bf' a g d |
  \clef bass
  c,8 g' ef'2 |
  ef,8 bf' c2 |
  bf,8 f' bf4 d |
 
  ef,8 gf bf c ef4 |
  bf,8 f' d'4 f |
  ef,8 c' ef gf ~ gf4 |
  bf,,8 f' d'4 a |
  g8 d' bf a g d |
}

melodyWordsDefault = \lyricmode {
  \set stanza = "1."
  Would I know my Sav -- ior
  %% The higher the details.strength property, the harder the algorithm
  %% tries to place the lyric syllable close to its ideal position, at the
  %% expense of other lyric syllables nearby.  Try outcommenting this
  %% override to see the effect.
  %\once \override LyricText.details.strength = 100
  %% \doReserveSpace can be used to exceptionally reserve space between the
  %% two following lyric words (in spite of the snippet turning this off).
  %% Try outcommenting to see the effect.
  %\doReserveSpace
  Wrapped in swad -- dling
  bands,
  Ly -- ing in a man -- ger bed, Light of hea -- ven ’round His head?
}

#(set-global-staff-size 19)
\paper {
  ragged-last = ##f
  ragged-bottom = ##t
  ragged-right = ##f
  ragged-last-bottom = ##t
  tagline = ##f
}

\layout {
  \context {
    \Lyrics
    \override LyricText.font-size = #0
    \override LyricHyphen.font-size = #-0.5
    \override LyricHyphen.padding = #0.15
    \override LyricHyphen.length = #0.6  %#0.4
    \override LyricHyphen.minimum-length = #0.66
    \override LyricHyphen.minimum-distance = #1 %0.15
    \override LyricHyphen.thickness = 2.0
    \override LyricHyphen.dash-period = 8.0
    \override LyricExtender.minimum-length = #0
    \override LyricExtender.right-padding = #0.5
    \override LyricSpace.minimum-distance = #1
    \override VerticalAxisGroup.nonstaff-relatedstaff-spacing.padding = #1
  }
}

\score {
  <<
    \new Staff <<
      \struct
      \new Voice = "melody" \melody
    >>
    \new Lyrics \lyricsto melody \melodyWordsDefault
    \new PianoStaff <<
      \new Staff = "pianoRH" <<
        \struct
        \pianoRH
      >>
      \new Staff = "pianoLH" <<
        \struct
        \pianoLH
      >>
    >>
  >>
  \layout {}
}

Attachment: OpenPGP_signature
Description: OpenPGP digital signature

Reply via email to