2025年04月12日

指揮付きの伴奏動画を作る:指揮の表示

 先日作成した、バスクラ練習用の指揮付き伴奏動画を作る手順をメモしておきます。こういうものです。

20250412-1.jpg

 伴奏音源は標準 MIDI ファイルで作ってあるものとします。拍の位置は、拍子メタイベントから計算できます。これは MIDI ティック単位なので、テンポメタイベントの情報を使って実時間に変換します。

 最初に、テンポイベントの一覧を作ります。

import mido   #  midoパッケージをインポート
def create_beatlist():            #  拍の位置についての情報(後述)を返す関数を定義
  mid = mido.MidiFile("...mid")   #  MIDI ファイルを読み込む
  timebase = mid.ticks_per_beat   #  MIDI ファイルのタイムベース(4分音符1個あたりのティック数)
  tempolist = [(0, 0.0, 500000)]  #  テンポイベントのリスト。[(tick, time, tempo)...]
  last_tick = 0                   #  直前のイベントのティック
  for msg in mid.tracks[0]:       #  mid.tracks[0] はコンダクタートラック。この中のイベントを処理する
    tick = last_tick + msg.time   #  このイベントのティック
    if msg.type == 'set_tempo':   #  テンポイベントか?
      last_tempo_tick = tempolist[-1][0]  #  直前のテンポイベントのティック値
      last_tempo_time = tempolist[-1][1]  #  直前のテンポイベントのタイム値(ティック値を実時間に直したもの)
      if tick == last_tempo_tick: #  同じ位置にテンポイベントがあるとき
        tempolist[-1] = (tick, last_tempo_time, msg.tempo)   #  tempolist 中の値を上書き
      else:                       #  そうでないとき
        #  タイム値を計算して tempolist に追加
        time = last_tempo_time + (tick - last_tempo_tick) * tempolist[-1][2] / timebase
        tempolist.append((tick, time, msg.tempo))
    last_tick = tick              #  ティック値を更新して次のイベントへ

 tempolist を使って、ティック値と実時間を変換するローカル関数です。

  def tick_to_time(tick):
    for t in reversed(tempolist):  #  ティック値上、またはそれより前にある最後のテンポイベントを探す
      if t[0] <= tick:
        return t[1] + (tick - t[0]) * t[2] / timebase  #  実時間に変換(マイクロ秒単位)
    return None
  def time_to_tick(time):
    for t in reversed(tempolist):
      if t[1] <= time:
        return t[0] + (time - t[1]) * timebase / t[2]
    return None

 次に、拍子イベントの一覧を作っておきます。MIDI ファイル中の拍子イベントは、「分子・分母・clocks_per_tick = メトロノーム1拍中のMIDIクロックの数・notated_32nd_notes_per_beat = MIDIクロック24個中の32分音符の数」という情報を持っています。1小節の長さは、四分音符単位で「分子×4/分母」です。MIDIクロック1個中の四分音符の数は notated_32nd_notes_per_beat / (8 * 24) ですから、メトロノーム1拍は四分音符単位で clocks_per_tick * notated_32nd_notes_per_beat / (8 * 24) となります。ティック単位にするには、これらの値にタイムベースをかければOKです。ティック単位にしたところで、丸め誤差を避けるため、整数化しておきます。

  siglist = [(0, timebase * 4, timebase)]  #  拍子イベントのリスト
  last_tick = 0                   #  直前のイベントのティック
  for msg in mid.tracks[0]:       #  コンダクタートラックのイベントを処理する
    tick = last_tick + msg.time   #  このイベントのティック
    if msg.type == 'time_signature':   #  拍子イベントか?
      num = msg.numerator         #  拍子記号の分子
      den = msg.denominator       #  拍子記号の分母
      cpc = msg.clocks_per_click  #  メトロノーム1拍分の長さ(MIDIクロック単位=四分音符の1/24)
      n = msg.notated_32nd_notes_per_beat  #  24 MIDI クロック中の32分音符の数
      siglist.append((tick, round(num * 4 * timebase / den), round(cpc * n * timebase / (8 * 24))))
    last_tick = tick

 拍子イベントを元に、それぞれの拍について「時刻, 何拍めか, 小節中の拍数」を決めて、リストを作ります。拍子イベントの位置を小節の先頭とします。

  beatlist = []  #  [時刻, 1小節の拍数, 何拍めか] のリスト
  tick = 0       #  現在のティック値
  for i, s in enumerate(siglist): #  拍子イベントの位置から処理する
    if i < len(siglist) - 1:      
      next_tick = siglist[i + 1][0]  #  次の拍子イベントの位置
    else:
      next_tick = time_to_tick(mid.length * 1000000.0) + s[2] + 1  # 曲の終わり
    beat_per_bar = math.ceil(s[1] / s[2])  #  割り切れないこともあり得るので切り上げる
    beat = 0
    while tick < next_tick:
      time = tick_to_time(tick)
      beatlist.append([time, beat_per_bar, beat])
      beat += 1
      if beat >= beat_per_bar:
        tick = tick - (beat - 1) * s[2] + s[1]  #  次の小節の先頭
        beat = 0
      else:
        tick += s[2]
    tick = next_tick
  return beatlist

 次に、指揮図について考えます。例えば3拍子の曲で、下のように指揮棒を動かすとします。座標の値は、中央の上を原点として、X軸を右向き、Y軸を下向きとして設定しています。単位は任意で、あとで画面に収まるように適宜調節します。拍のタイミングは図に示した通りで、基本的には拍の頭で「上から下に叩く」動きですね。(小節先頭を「0拍」と記述)

20250412-3.jpg

 指揮棒の動きはいろいろなスタイルがありますが、「等加速度運動」が自然に見えるようです。そこで、下のような式で棒の位置を計算します。棒の縦方向の動きが等加速度運動になり、上端でもっとも遅く、下端で最も速くなるようにします。

20250412-4.jpg

 横方向は、動きが放物線状になるように決めています。このため、棒全体の動きとしては等加速度運動にはなっていませんが、「動きが予測できること」と「動きがスムーズであること」を両立させた方がよいかなと考えて、このようにしました。

20250412-5.jpg

 これを元に、時刻から指揮棒の位置を計算する関数を作りました。positions は、「m拍子のn拍めの指揮棒の位置」を保持しています。6拍子までしか対応してませんが、まあ実用上問題は少ないかな。

positions = {
  1: [(0, 0), (0, 1), (0, 0)],
  2: [(0, 0), (0, 1), (0.5, 0.2), (0, 1), (0, 0)],
  3: [(0, 0), (-0.3, 1), (-0.6, 0.3), (0, 1), (0.6, 0.3), (0.3, 1), (0, 0)],
  4: [(0, 0), (0, 1), (0, 0.2), (-0.25, 1), (-0.5, 0.2), (0, 1), (0.5, 0.2), (0.25, 1), (0, 0)],
  5: [(0, 0), (0, 1), (0, 0.2), (-0.25, 1), (-0.5, 0.2), (0, 1), (0, 0.4), (0.25, 1), (0.65, 0.3), (0.35, 1), (0, 0)],
  6: [(0, 0), (0, 1), (0, 0.2), (-0.25, 1), (-0.35, 0.5), (-0.5, 1), (-0.65, 0.5), (0, 1), (0, 0.6), (0.25, 1), (0.65, 0.4), (0.35, 1), (0, 0)]}

#  conductor position for "n-th beat out of num beats, plus timing b (-1/2 to 1/2)
def conductor_pos(num, n, b):
  list = positions[num]
  i = n * 2 + 1
  if b < 0:
    r = (1 + b * 2) ** 2   #  (-1/2, 0) -> (0, 1), quadratic conversion
    x = list[i][0] + (list[i - 1][0] - list[i][0]) * (1 - r) ** 0.5  #  "**0.5" makes parabola trace
    y = list[i - 1][1] + (list[i][1] - list[i - 1][1]) * r
  else:
    r = (1 - b * 2) ** 2   #  (0, 1/2) -> (1, 0), quadratic conversion
    x = list[i][0] + (list[i + 1][0] - list[i][0]) * (1 - r) ** 0.5
    y = list[i + 1][1] + (list[i][1] - list[i + 1][1]) * r
  return (x, y)

 画面表示には pygame を使います。まず画面の初期化です。

import pygame
WIDTH = 1280     #  Screen width
HEIGHT = 720     #  Screen height
#  Initinalize pygame screen
#  screen size is (WIDTH, HEIGHT)
def init_screen():
  global screen
  pygame.init()
  screen = pygame.display.set_mode((WIDTH, HEIGHT))
  pygame.display.set_caption("Make Movie Test [no score]")

 画面更新の関数です。etime は曲の先頭からの経過時間、beatlist は先ほど計算した拍情報のリストです。「現在の指揮棒の位置」を表示するだけでは、目で動きを追うのが難しかったので、残像を一緒に表示することにしました。

#  Update screen
def update_screen(etime, beatlist):
  global screen, videosave
  screen.fill((0, 0, 0))          #  スクリーンをクリア
  #  beatlist[i][0] <= etime < beatlist[i+1][0] になる i を見つける
  i = 0
  while i < len(beatlist) and beatlist[i][0] <= etime:
    i += 1
  i -= 1
  #  指揮図を描く
  history = []
  j = i
  for k in range(15):
    #  10ミリ秒刻みで過去15点分の指揮棒の位置を計算する(残像)
    et = etime - k * 10000.0
    if et <= 0:
      beat = beatlist[0]
      pos_x, pos_y = conductor_pos(beat[1], beat[2], 0)
    elif etime >= beatlist[-1][0]:
      pos_x = 0
      pos_y = 1
    else:
      while et < beatlist[j][0]:
        j -= 1
      b = (et - beatlist[j][0]) / (beatlist[j + 1][0] - beatlist[j][0])
      if b < 0.5:
        beat = beatlist[j]
        pos_x, pos_y = conductor_pos(beat[1], beat[2], b)
      else:
        beat = beatlist[j + 1]
        pos_x, pos_y = conductor_pos(beat[1], beat[2], b - 1.0)
    pos_x = int((pos_x + 1) * 0.5 * WIDTH)
    pos_y = int(pos_y * (HEIGHT - 40)) + 20
    history.append((pos_x, pos_y))
  for ih in range(len(history) - 1, -1, -1):
    pygame.draw.circle(screen, (0, 255 - ih * 17, 0), history[ih], 20, 0)
  pygame.display.update()

 伴奏の音声ファイルを再生しながら、画面表示します。音声ファイルはあらかじめ SMF から作成しておきます。

init_screen()                        #  画面を初期化
beatlist = create_beatlist()         #  拍情報のリストを作成
pygame.mixer.music.load(soundfile)   #  音声ファイルをロード
pygame.time.wait(2000)               #  2秒待つ
pygame.mixer.music.play()            #  音声ファイルの再生開始
start_time = pygame.time.get_ticks() #  開始時点の時刻
while True:
  now_time = pygame.time.get_ticks()
  update_screen((now_time - start_time) * 1000, beatlist) # 画面更新
  for event in pygame.event.get():
    if event.type == pygame.QUIT:  
      pygame.quit()
      sys.exit()
  pygame.time.wait(25)

 これで、指揮棒の動きを伴奏音声に合わせて表示できました。楽譜の表示と、動画ファイルの作成については別記事で書きます。

Posted at 2025年04月12日 10:53:49
email.png