2025年05月17日

指揮付きの伴奏動画を作る:スコアとガイドの表示

 「指揮付きの伴奏動画を作る:指揮の表示」の続きです。画面にスコアを表示して、現在位置のガイドをつけたいのです。

20250517-1.jpg

 実は、LilyPond の派生プロジェクトとして ly2video というのがあります。これを使えば、スクロールする楽譜にガイドをつけることができます。しかしながら、インストールが妙に複雑なのと、やりたいことが微妙に違っているので、ガイドをつける技法を参考にさせてもらって、コーディングは自前でやることにしました。

 LilyPond を使います。まず、\paper セクションに以下のように指定して、1ページに1段ずつ楽譜を組版します。ヘッダ・フッタはすべて非表示にします。

  systems-per-page = 1
  oddHeaderMarkup = ##f
  evenHeaderMarkup = ##f
  bookTitleMarkup = ##f
  scoreTitleMarkup = ##f
  tagline = ##f

 拍の位置を出力させる方法を探すのに苦労しました。作戦は以下の通りです。

  • 拍の位置に \markup テキストを置いて、stencil##f にして、表示されないようにする。
  • TextScriptafter-line-breaking コールバックを設定する。このコールバック関数中で、ly:grob-relative-coordinate を呼ぶと、現在の譜表 (System) に対する相対座標が求められる(これを見つけるのに苦労した)。
  • 楽曲の先頭からの位置は、(grob::when grob) で取得できる。ly:moment-main で全音符の長さの倍数に変換できて、タイムベース×4 (1536) を掛けると MIDI ティックに変換できる。
  • 小節線の位置も同様に取得できる。コールバックを設定するとき、BarLineVoice コンテキストではなく Staff コンテキストに属するので、Staff.BarLine.after-line-breaking と記述する必要がある。

 これに基づいて、下のようにコーディングしました。\bsclA に音楽の本体が入っている想定です。

#(define output-port (open-output-file "part_movie.out"))
#(define (calc-XY-position grob)
      (let* ((system (ly:grob-system grob))
             (x (ly:grob-relative-coordinate grob system X))
             (y (ly:grob-relative-coordinate grob system Y)))
             (format output-port "~a ~a ~a\n" (grob::name grob) x y (* 1536 (ly:moment (grob::when grob))))))
beat = #(define-event-function () () #{ -\tweak TextScript.stencil $##f ^\markup "!" #})
\score {
    \new Staff = "bsclar" {
      %lt;%lt; { \transpose c d' \bsclA }
         {  \override TextScript.after-line-breaking = #calc-XY-position
            \override Staff.BarLine.after-line-breaking = #calc-XY-position
            \beat \beat   % two empty beats before beginning of the score
            \repeat unfold 111 { s4.\beat s4.\beat } }
      >>
    }
}

 part_movie.out には下のような内容が出力されます。数字の1つめが System の左端からの位置(単位は譜線間の距離 = staff-size を4で割ったもの。Staff-size の単位は point = 1/72.27 inch)。数字の3つ目は先頭からのティック値です。

TextScript 11.129561264437905 -1.2233492063492082 0
TextScript 11.129561264437905 -1.2233492063492082 0
TextScript 11.129561264437905 -1.2233492063492082 0
TextScript 14.633786412225351 -1.2233492063492082 576
TextScript 19.26695255310438 -1.2233492063492082 1152
BarLine 18.1380115600128 -3.777000000000002 1152
…

 System の紙面内の位置を取得する方法が、どうしてもわかりませんでした。1ページのレイアウトが終わった時点で決まるはずなのですが、どこにコールバックを引っ掛ければいいのかがわからない。結局、ly2video のソースを参照して、1ページ分を png 形式にした画像を左上から1ピクセルずつ見ていって、「白でないピクセルが真横に一定の長さ並んでいる」ところを探して、それを一番上の譜線の位置にすることにしました。力技です。Python で書いています。

from PIL import Image
def find_staff_rect(image):  #  image は Image 型の画像オブジェクトとする
  lines = []
  width, height = image.size
  for y in range(height):
    for x in range(width):
      if image.getpixel((x,y)) != 255:
        for len in range(width - x):
          if image.getpixel((x+len,y)) == 255:
            lines.append((y, x, len))
            break
        break
  lines.sort(key = lambda a: -a[2] * height + a[0])  #  sort by len (descending order) and then by y (ascending order)
  #  The y range of the staff
  staff_y0 = 1000000
  staff_y1 = -1
  staff_len = lines[0][2]
  staff_x = lines[0][1]
  for a in lines:
    if a[2] < staff_len - 3:
      break   #  This is no longer part of the staff
    staff_y0 = min(staff_y0, a[0])
    staff_y1 = max(staff_y1, a[0])
  return (staff_x, staff_y0, staff_len, staff_y1 - staff_y0)

 1ページ1段で組版した楽譜の PDF から1ページごとに画像を切り出して、それぞれの譜線の位置を計算するところまでコーディングしました。PDF のページ数を求めるのに pdfinfo、PDF から画像に変換するのに ImageMagick を使っています。どちらも Homebrew でインストールしたものです。

import pygame

basename = "part_movie"
titlefile = "title.png"

WIDTH = 1280     #  Screen width
HEIGHT = 720     #  Screen height
PPI = 154        #  Pixels per inch in the page image

def create_png_files():
  global page_images, staff_rects, max_height, title_image
  staff_rects = []   #  List of (x, y, width, height) corresponding to the staff in each pages
  page_images = [] #  List of pygame surfaces
  np = subprocess.check_output(["bash", "-c", "pdfinfo {}.pdf | grep 'Pages:' | awk '{{ print $2 }}'".format(basename)])
  np = int(np)
  max_height = 0
  for p in range(np):
    pngname = "page{:04}.png".format(p)
    if (not os.path.exists(pngname)) or os.path.getmtime(pngname) < os.path.getmtime(basename + ".pdf"):
      print("Creating {}.".format(pngname))
      os.system("magick -density {} {}.pdf[{}] -alpha remove -define trim:edges=south,north -trim +repage" \
        " -gravity west -extent {} +repage -gravity northwest -splice 0x10 -gravity southwest -splice 0x10 +repage" \
        " {}".format(PPI, basename, p, WIDTH, pngname))
    image = Image.open(pngname)
    staff_rects.append(find_staff_rect(image))
    image = image.convert("RGB")
    if max_height < image.size[1]:
      max_height = image.size[1]
    page_images.append(pygame.image.fromstring(image.tobytes(), image.size, image.mode).convert())
    print(page_images[-1])
  title_image = pygame.image.load(titlefile)

 これで、一番大変なところを突破しました。あとは、先日検討した指揮の表示と合わせて動画を作成します。長くなったので、別記事で書きます。

Posted at 2025年05月17日 23:26:18
email.png