LilyPond

LilyPondで増一度を記譜する

More than 1 year has passed since last update.

皆さん、短二度は好きですか?私はめちゃくちゃ好きです。

さて、諸々の理由から、半音でぶつかる和音を短二度ではなく増一度で記譜したい場合が出てきます。この時、同じ高さに臨時記号の異なる2つの音符を書かなければならないため、符幹を枝分かれさせて記譜することが一般的です。

eritana.png
(アルベニス「イベリア」第4巻より「エリターニャ」第27小節)

これをLilyPondでやりたいというのがこの記事の趣旨です。

Snippet RepositoryにはDisplaying complex chordsというスニペットが存在します。なるほど別のVoiceを作ってStemを回転させるんですね。でも、画像のような記譜をすることは難しそうです。

そこで、増一度をまあまあ簡単に作れるスニペットを作ってみました。ちなみに、2.18では動きませんので注意してください。

コード

#(set-object-property! 'stem-fork-position 'backend-type? pair?)
#(set-object-property! 'skip-stem-fork-printing 'backend-type? boolean?)

#(define (notehead-fork-x-offset offset head-direction)
   (lambda (grob)
     (let* ((stencil (ly:grob-property grob 'stencil))
            (extent-x (ly:stencil-extent stencil X))
            (width (interval-length extent-x))
            (line-thickness (ly:staff-symbol-line-thickness grob))
            (stem-grob (ly:grob-object grob 'stem))
            (stem-width (* (ly:grob-property stem-grob 'thickness) line-thickness))
            (stem-direction (ly:grob-property stem-grob 'direction)))
       (cond
        ((and (= stem-direction UP) (= head-direction RIGHT))  (+ offset width (- stem-width)))
        ((and (= stem-direction DOWN) (= head-direction LEFT)) (- offset width (- stem-width)))
        (else offset)))))

#(define (stem-fork-stencil lst)
   (lambda (grob)
     (let* ((default-stencil (ly:stem::print grob))
            (default-extent-x (ly:stencil-extent default-stencil X))
            (default-extent-y (ly:stencil-extent default-stencil Y))
            (stem-start-y (interval-start default-extent-y))
            (stem-end-y (interval-end default-extent-y))
            (width (interval-length default-extent-x))
            (note-column (ly:grob-parent grob X))
            (note-head-grobs (ly:grob-array->list (ly:grob-object note-column 'note-heads)))
            (stem-direction (ly:grob-property grob 'direction)))
       (apply ly:stencil-add
         (map (lambda (note-head-grob vec)
               (let* ((note-head-stencil (ly:grob-property note-head-grob 'stencil))
                      (skip (ly:grob-property note-head-grob 'skip-stem-fork-printing #f))
                      (fork (ly:grob-property note-head-grob 'stem-fork-position '(2.75 . 3.5)))
                      (fork-start (car fork))
                      (fork-end (cdr fork))
                      (head-position (vector-ref vec 2))
                      (note-head-height (interval-length (ly:stencil-extent note-head-stencil Y)))
                      (staff-position (ly:grob-property note-head-grob 'staff-position))
                      (stem-attachment (ly:grob-property note-head-grob 'stem-attachment))
                      (start-y-raw ((if (= head-position RIGHT) - +) staff-position (* note-head-height (cdr stem-attachment))))
                      (start-y (/ start-y-raw 2))
                      (start-x (vector-ref vec 0)))
                (if skip
                    empty-stencil
                    (grob-interpret-markup grob
                     (markup (#:path width
                               (cond
                                ((= stem-direction UP)
                                 (list (list 'moveto start-x start-y)
                                       (list 'lineto start-x (max (- stem-end-y fork-end) start-y))
                                       (list 'lineto 0 (- stem-end-y fork-start))
                                       (list 'lineto 0 stem-end-y)))
                                ((= stem-direction DOWN)
                                 (list (list 'moveto start-x start-y)
                                       (list 'lineto start-x (min (+ stem-start-y fork-end) start-y))
                                       (list 'lineto 0 (+ stem-start-y fork-start))
                                       (list 'lineto 0 stem-start-y)))
                                (else empty-stencil))))))))
              note-head-grobs lst)))))

forkedChord = #(define-music-function
  (lst chord) (list? ly:music?)
  (let* ((note-event-list (ly:music-property chord 'elements)))
    (begin
     (for-each (lambda (note vec)
                 (set! (ly:music-property note 'tweaks)
                       (append (list
                                (cons (cons 'NoteHead   'X-offset) (notehead-fork-x-offset (vector-ref vec 0) (vector-ref vec 2)))
                                (cons (cons 'Accidental 'X-offset) (vector-ref vec 1)))
                               (ly:music-property note 'tweaks))))                       
       note-event-list lst)
     #{
       \once \override Stem.stencil = #(stem-fork-stencil lst)
       \once \override Accidental.before-line-breaking = #(lambda (grob) (ly:grob-set-parent! grob X (ly:grob-parent grob Y)))
       $chord
     #})))

使い方

\forkedChord lst chord`

lst: 和音内の音符の順で、ベクトル#(note-head-offset accidental-offset direction)をリストにして指定します。note-head-offsetは符頭の位置(正確には符幹の伸び始める位置)の水平オフセット、accidental-offsetは臨時記号の(符頭との相対)水平オフセット、directionLEFTまたはRIGHTで、符幹のどちら側に符頭を置くかを指定します。
chord: 和音です。

このコマンドを用いると、指定された和音の符幹が書き換わります。それぞれの符頭から符尾に向かって線が描かれたものが符幹になります。NoteHead.skip-stem-fork-printing#tにセットされると、その符頭からは線が描かれないようになります。

NoteHead.stem-fork-positionにペアを指定することで、符幹が分岐する始点と終点の垂直位置を符尾からの譜スペースで指定できます。デフォルトでは'(2.75 . 3.5)です。

また、お好みでStem.lengthなどを変更してください。

サンプル

\relative {
  \autoBeamOff
  \once \override Stem.length = 10
  \forkedChord #`(#(-1.5 -1.0 ,LEFT) #(1.5 -1.3 ,LEFT) #(1.5 0 ,LEFT))
  <c'! cis e>8
  \once \override Stem.length = 10
  \forkedChord #`(#(-3.0 -1.0 ,LEFT) #(0.0 -1.3 ,LEFT) #(0.0 0 ,RIGHT))
  <ces cis d>8
  \once \override Stem.length = 12
  \forkedChord #`(#(-1.5 -1.0 ,LEFT) #(1.5 -1.3 ,LEFT) #(1.5 0 ,LEFT))
  <\tweak NoteHead.stem-fork-position #'(2.75 . +inf.0) c! cis a>8
  \once \override Stem.length = 10
  \forkedChord #`(#(-1.5 -1.0 ,LEFT) #(1.5 -1.3 ,LEFT) #(1.5 0 ,LEFT))
  <\tweak NoteHead.skip-stem-fork-printing ##t c! cis e>8

  \stemDown
  \once \override Stem.length = 10
  \forkedChord #`(#(-1.5 -1.0 ,RIGHT) #(1.5 -1.3 ,RIGHT) #(1.5 0 ,RIGHT))
  <c'! cis e>8
  \once \override Stem.length = 10
  \forkedChord #`(#(-4.0 -1.0 ,RIGHT) #(0.0 -1.3 ,LEFT) #(0.0 0 ,RIGHT))
  <ces cis d>8
  \once \override Stem.length = 12
  \forkedChord #`(#(-1.5 -1.0 ,RIGHT) #(1.5 -1.3 ,RIGHT) #(1.5 0 ,RIGHT))
  <\tweak NoteHead.stem-fork-position #'(2.75 . +inf.0) c! cis a>8
  \once \override Stem.length = 10
  \forkedChord #`(#(-1.5 -1.0 ,RIGHT) #(1.5 -1.3 ,RIGHT) #(1.5 0 ,RIGHT))
  <\tweak NoteHead.skip-stem-fork-printing ##t c! cis e>8
}

forking-chord.png

雰囲気、伝わりました?

説明

あまり綺麗な実装ではないですが、一応技術的な解説をしておこうと思います。

大まかにやってること

  1. 専用のGrobプロパティを定義する
  2. lstに基づいて、和音の符頭(NoteHead)の位置と、臨時記号(Accidental)の位置をずらす
  3. 和音の符幹(Stem)のstencilを書き換える
  4. 臨時記号のY軸方向の親Grob(参照ポイント)を置き換える

Grobプロパティを定義する

#(set-object-property! 'stem-fork-position 'backend-type? pair?)
#(set-object-property! 'skip-stem-fork-printing 'backend-type? boolean?)

セットできるプロパティのリストは、Grobの種類に関係なくグローバルに予め設定されており(こんな感じ)、リストに存在するプロパティであれば、それを用いるインターフェースを実装していなくとも、あらゆるGrobに設定することができます。逆に、リストに存在しないプロパティを設定しようとすると、警告が表示され全くセットされません。何らかの拡張を加える際に、独自の名前のプロパティを使用したい場合には、新たなGrobプロパティを定義する必要があります。

新たなGrobプロパティを定義するには、以下のようにします。

#(set-object-property! name property type-check)

nameはプロパティ名をシンボルで指定します。
propertyはプロパティの種類を指定します。Grobプロパティの場合は'backend-type?です。
type-checkは型チェックを行う関数です。boolean?number?などがあります。記譜法リファレンス A.21 Predefined type predicatesを参照してください。

最低限これを予め宣言しておくことで、有効なプロパティとして認識されるようになります。

今回このスニペットでは、stem-fork-positionskip-stem-fork-printingというプロパティを定義しています。

和音内の各音符をtweakする

  (let* ((note-event-list (ly:music-property chord 'elements)))
    …
     (for-each (lambda (note vec)
                 (set! (ly:music-property note 'tweaks)
                       (append (list
                                (cons (cons 'NoteHead   'X-offset) (notehead-fork-x-offset (vector-ref vec 0) (vector-ref vec 2)))
                                (cons (cons 'Accidental 'X-offset) (vector-ref vec 1)))
                               (ly:music-property note 'tweaks))))
       …
     #{
       …
       $chord
     #})))

\displayMusic <~>で和音を見てみるとわかりますが、和音はEventChordというイベントとして出現します。elementsプロパティが各音符のリストを保持しています。
各音符はNoteEventというイベントになっていて、これらのtweaksプロパティを書き換えることで、このイベントを通して発生するGrobのプロパティを設定することができます。今回のスニペットでは、音符のリストと引数のリストに対してfor-eachを適用して、各音符に異なるプロパティを設定するようにしてあります。

X-offsetに要注意

NoteHead.X-offsetAccidental.X-offsetはデフォルトでは、和音として配置される際に特殊な挙動を取ります。これは、自身の位置が和音内の他の音の配置に依存するためです。
具体的にNoteHeadでは、和音内の最初の音符の位置を決める際に、X-offsetが読み取られますが、これにはデフォルトでコールバック関数ly:note-head::stem-x-shiftがセットされています。この関数の実態は、そのNoteHeadの属するStemオブジェクトのpositioning-doneを読み取るだけのものです。最初はこれにly:stem::calc-positioning-doneがセットされていて、やはり読み取ればコールバック関数が実行されます。そしてこの関数内で、全てのNoteHeadの水平位置が確定されます。一度呼ばれるとpositioning-done#fにセットされるため、和音内で一度しかこの関数が呼ばれないようになっています。

Accidentalでもだいたい同じような動作を行います。ただし、衝突解決はStemではなくAccidentalPlacementが担います。

2.19.80現在、X-offsetにデフォルトで設定されているコールバックが呼ばれた場合、以下のような挙動をします。

  • NoteHead.X-offsetに設定されているly:note-head::stem-x-shiftは、呼ばれると、和音内でそれ以降に出現する符頭の位置を確定します。
  • Accidental.X-offsetに設定されているly:grob::x-parent-positioningは、一度でも呼ばれると、和音内の全ての臨時記号の位置を確定します。

したがって、これらのX-offsetの値を正しくセットしたい時には、和音内の全ての音符についてX-offsetを書き換える必要があります。

符頭や符幹の幅を得る

#(define (notehead-fork-x-offset offset head-direction)
   (lambda (grob)
     (let* ((stencil (ly:grob-property grob 'stencil))
            (extent-x (ly:stencil-extent stencil X))
            (width (interval-length extent-x))
            (line-thickness (ly:staff-symbol-line-thickness grob))
            (stem-grob (ly:grob-object grob 'stem))
            (stem-width (* (ly:grob-property stem-grob 'thickness) line-thickness))
            (stem-direction (ly:grob-property stem-grob 'direction)))
       (cond
        ((and (= stem-direction UP) (= head-direction RIGHT))  (+ offset width (- stem-width)))
        ((and (= stem-direction DOWN) (= head-direction LEFT)) (- offset width (- stem-width)))
        (else offset)))))

さて、NoteHead.X-offsetの設定時に、notehead-fork-x-offsetという関数を呼び出しています。これは、符頭の位置を引数に与えた時に、Grobプロパティに設定できるコールバック関数を返す高階関数です。値を直接設定せずに、関数をセットしているのは、現時点ではオフセットを確定することができないからです。

というのも、符幹が上向きで符頭が右側に付く場合や、符幹が下向きで符頭が左側に付く場合には、更に符頭の位置をずらさなければいけません。ずらす量は符頭や符幹の幅に依存します。この関数内で、それらを取得し、実際にずらす量を確定しています。

width.png

符頭はly:grob-propertyからstencilを取得して、その大きさを測っています。Grobのstencilの大きさを知るには、ly:stencil-extentinterval-lengthを使うと良いでしょう。
符幹の幅はStem.thickness * line-thicknessで与えられます。line-thicknessly:staff-symbol-line-thicknessから取得することができます。もちろん、符頭と同じようにstencilを取得して幅を計算しても良いと思います。

stencilを作る

         (map (lambda (note-head-grob vec)
               (let* ((note-head-stencil (ly:grob-property note-head-grob 'stencil))
                      (skip (ly:grob-property note-head-grob 'skip-stem-fork-printing #f))
                      (fork (ly:grob-property note-head-grob 'stem-fork-position '(2.75 . 3.5)))
                      (fork-start (car fork))
                      (fork-end (cdr fork))
                      (head-position (vector-ref vec 2))
                      (note-head-height (interval-length (ly:stencil-extent note-head-stencil Y)))
                      (staff-position (ly:grob-property note-head-grob 'staff-position))
                      (stem-attachment (ly:grob-property note-head-grob 'stem-attachment))
                      (start-y-raw ((if (= head-position RIGHT) - +) staff-position (* note-head-height (cdr stem-attachment))))
                      (start-y (/ start-y-raw 2))
                      (start-x (vector-ref vec 0)))
                (if skip
                    empty-stencil
                    (grob-interpret-markup grob
                     (markup (#:path width
                               (cond
                                ((= stem-direction UP)
                                 (list (list 'moveto start-x start-y)
                                       (list 'lineto start-x (max (- stem-end-y fork-end) start-y))
                                       (list 'lineto 0 (- stem-end-y fork-start))
                                       (list 'lineto 0 stem-end-y)))
                                ((= stem-direction DOWN)
                                 (list (list 'moveto start-x start-y)
                                       (list 'lineto start-x (min (+ stem-start-y fork-end) start-y))
                                       (list 'lineto 0 (+ stem-start-y fork-start))
                                       (list 'lineto 0 stem-start-y)))
                                (else empty-stencil))))))))

使い方に書いた通り、このスニペットは符頭ごとに線を1本引きます。まず、それぞれの線を1つのstencilとして生成し、それらをly:stencil-addに渡してやることで、それらを重ねた新しいstencilを生成します。skip-stem-fork-priting#tの時には何も描かれないようにしなくてはならないため、empty-stencilで空のstencilを返すようにしています。

stencilのお絵かきには、grob-interpret-markupを使うのがオススメです。マークアップからstencilを作ることができます。なお、マークアップ内での座標は、staff-positionの値を1/2しなければならないようです。

さて、符頭のどのあたりから符尾を伸ばせば良いでしょうか?X座標は符頭の端とします。(そのためこのスニペットでは、符幹が符頭の中央に伸びるような符頭を使った時に表示が崩れてしまいます。)Y座標はどうでしょうか。NoteHead.stem-attachmentで、これを知ることができます。これは、符頭の中央を(0 . 0)として、右上の端を(1 . 1)とするような座標系で設定されています。通常の符頭では、だいたい(1.0 . 0.34)ぐらいです。

attachment.png

この値と、符頭の高さを掛けてやれば、符幹を引き始めるべきY座標が分かるというわけです。

臨時記号の親Grobを置き換える

\once \override Accidental.before-line-breaking = #(lambda (grob) (ly:grob-set-parent! grob X (ly:grob-parent grob Y)))

各Grobは、軸ごとに親Grobを持っています。親の役割は、Grobの相対位置の基準となることです。Grobのオフセットが0の時の位置は、それぞれの軸の親に合わせられるということです。
例えばAccidentalは、X軸方向の親としてAccidentalPlacement、Y軸方向の親としてNoteHeadを持っています。臨時記号の垂直方向の位置は対応する符頭に合わせられていれば自然ですよね。

さて、このスニペットでは、臨時記号の水平位置を符頭との関係で指定したいわけです。そのためには、X軸方向の親をNoteHeadに書き換えると良いですね。親を置き換えるのは半ば強引な気がしますが、このスニペットでは臨時記号の配置にAccidentalPlacementを使わないため、思い切って変えてしまいました。

問題なのは、どのタイミングで置き換えるかです。\override\setのような構文が用意されているわけではありません。どこか上手いところでGrobをSchemeで直接操作する必要があります。

ここでbefore-line-breakingの出番です。これにコールバック関数を指定すると、必要なGrobが用意されてから、Grobの行内位置が決まる前のタイミングで何らかの操作を行うことができます。

既知の問題と警告

(見出しは公式マニュアルのパクリ)

  • 臨時記号を符頭の右側に動かすと死にます。原因はよく分かってません。
  • 通常の符頭で用いることを想定しています。符幹が符頭の中央に伸びるような符頭については対応していません。
  • アーティキュレーションの位置は、和音の最後の音符に合わせられます。それ以外の音符の位置に付けたい場合、あるいは符尾側に付けたい場合、オフセットを手動で指定する必要があります。
  • 構文から分かる通り、臨時記号の自動配置に対応していません。

他にも色々ありそうです。何かあったらコメントで指摘していただけると幸いです。