蛇ノ目の記

技術のことも。そうでないことも。

midoを使ってPythonでMIDIファイルを作る - テキストからメロディを作ってみた

半年以上からMIDIコントローラーを買ったりしていたものの何もしないまま歳を越してしまったわけだが、とうとう軽く触ってみることを始めた。

とりあえず『ゼルダの伝説 時のオカリナ』の"時の歌"を打ち込んでlofiっぽい感じにして遊んでみた。

MIDIはファイルとして管理されているのだからPythonで記述できるのでは、と思い探してみるとmidoというライブラリが見つかった。

pypi.org

midoの基礎

midを使うと簡単にMIDIファイルを作成できる。以下のコード例は、ドの音の四分音符を一つ鳴らすだけのシンプルすぎる例。

import mido
from mido import Message, MidiFile, MidiTrack, MetaMessage

mid = MidiFile()
track = MidiTrack()
mid.tracks.append(track)
# BPM120のMIDIトラックを作成
track.append(MetaMessage('set_tempo', tempo=mido.bpm2tempo(120)))  #  BPMを120に設定

# C3(ド)の音の四分音符をMIDIトラックに追加 (C3はいわゆる真ん中の高さのド)
track.append(Message('note_on', note=60, velocity=64, time=0))
track.append(Message('note_off', note=60, time=480))

# MIDIファイルを保存
mid.save("new_song.mid")

C3が60で表されることがわかれば、他の音もわかる。

60: "C",
61: "C#",
62: "D",
63: "D#",
64: "E",
65: "F",
66: "F#",
67: "G",
68: "G#",
69: "A",
70: "A#",
71: "B",

オクターブを変えたいときはそれぞれの値に±12をすればよい。

自由研究

俺はメロディを思いつくことがほぼできないので、自動化しようと思い立った。

初めはなんらかのAPIで取れる連続的なレスポンスをどうにかしてメロディに変換しようと思ったが、天気系のAPIの使い方を調べているうちに面倒になり、まずはシンプルにテキストの長さを音の並びに変換しようという考えになった。

というわけで、手始めにWebエンジニアの多くが見たことがあるであろう"Lorum ipsum ..."を題材とした。

Lorum ipsum ... is 何

Webデザインなどにおいて、文章を入れる箇所に仮置きするダミーテキスト。

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

処理の流れ

  1. テキストをスペースで区切って、単語に分ける。カンマやピリオドは削除しておく
  2. 単語の長さの最小値、最大値を取得する
  3. 最小値<=n<=最大値の範囲の数列を、スケールの構成音の数で分割する
  4. それぞれの単語の長さをスケールの構成音に変換する
  5. MIDIトラックに音を追加していく
  6. MIDIファイルに保存する

Cメジャースケールでの例

Lorem ipsum dolor sit amet, . . .

["Lorem", "ipsum", "dolor", "sit", "amet"]

メジャースケールの構成音は「1, 3, 5, 6, 8, 10, 12, 13」なので、分割数は8となる。

ダミーテキストの単語の最小値は2, 最大値は17なので、この範囲を8分割する。

[array([2, 3]), array([4, 5]), array([6, 7]), array([8, 9]), array([10, 11]), array([12, 13]), array([14, 15]), array([16, 17])]

各単語の長さを、この配列に当てはめて音を決定する。

[5, 5, 5, 3, 4, . . .]

[1, 1, 1, 0, 1, ...]

となる。

キーがC(ド)の場合「レ, レ, レ, ド, レ」となる。

各音をMIDIトラックに追加、ファイルを保存して完了となる。

音の長さと強さ

音の長さは4分音符、2分音符、全音符をランダムに割り振っている。

音の強さ(ベロシティ)も64を基準に±10の範囲でランダムに設定している。

スケールの構成音の取得

スケール名と構成音の一覧を記載しているWebサイトをスクレイピングして一覧用のファイルを作成した。

スクリプト実行時にスケール名のIDを入力して、使用するスケールを決定する。

scale-player.vercel.app

琉球音階を使った楽曲の例

今後の展望

  • 青空文庫にある文学作品からメロディを生成する
  • スケールの構成音からコードを生成する

付録

scraping.py

import csv
from pathlib import Path
import re

from bs4 import BeautifulSoup as bs
import requests

URL = "https://scale-player.vercel.app/scale_type"
PAT = re.compile(r"([\d]){1,2}")

res = requests.get(URL)

soup = bs(res.content, "html.parser")

h3_tags = soup.find_all("h3")
scales = []
for h3 in h3_tags:
    try:
        scales.append(h3["id"])
    except KeyError:
        pass

with Path("scales.csv").open("w") as f:
    fieldnames = ("idx", "scaleName", "notes")
    writer = csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader()
    idx = 1
    for scale in scales:
        h3 = soup.find("h3", id=scale)
        parent = h3.parent
        dl = parent.css.select("dl > dd")
        for elem in dl:
            detail = elem.find("p").get_text("\n").split("\n")[0]
            notes = detail.replace("構成音:", "")
            scale_name = elem.find("img")["alt"]
            writer.writerow({"idx": f"{idx:02d}", "scaleName": scale_name, "notes": notes})
            idx += 1

main.py

from collections import defaultdict
import csv
import numpy as np
from pathlib import Path
import random

import mido
from mido import Message, MidiFile, MidiTrack, MetaMessage

PATH = Path("scales.csv")
KEY_MESSAGE = (
    "- C3 : 60\n"
    "- C#3: 61\n"
    "- D3 : 62\n"
    "- D#3: 63\n"
    "- E3 : 64\n"
    "- F3 : 65\n"
    "- F#3: 66\n"
    "- G3 : 67\n"
    "- G#3: 68\n"
    "- A3 : 69\n"
    "- A#3: 70\n"
    "- B3 : 71\n"
)
KEY_DICT = {
    60: "C",
    61: "C#",
    62: "D",
    63: "D#",
    64: "E",
    65: "F",
    66: "F#",
    67: "G",
    68: "G#",
    69: "A",
    70: "A#",
    71: "B",
}
PITCH_RANGE = 12
SAMPLE_SENTENCE = (
    "Lorem ipsum dolor sit amet, "
    "consectetur adipiscing elit, "
    "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
    "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
    "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."
    "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
)


def load_scales():
    scales = defaultdict()
    with PATH.open("r") as f:
        fieldnames = ("idx", "scaleName", "notes")
        reader = csv.DictReader(f, fieldnames=fieldnames)
        next(reader)
        for row in reader:
            idx = row["idx"]
            scales[idx] = row["notes"].split(",")
    return scales

def fetch_sentence_lengths():
    sentences = SAMPLE_SENTENCE.split(" ")
    sentences = [sentence.replace(",", "").replace(".", "") for sentence in sentences]
    lengths = [len(sentence) for sentence in sentences]
    return lengths


def length_to_scale_notes(lengths, scales):
    print(lengths)
    scale_length = len(scales)
    split_base = np.array_split(
        list(range(min(lengths), max(lengths) + 1)),
        scale_length
    )
    print(split_base)
    scale_positions = []
    for length in lengths:
        for idx, base in enumerate(split_base):
            if base[0] <= length <= base[-1]:
                scale_positions.append(idx)
    scale_notes = [int(scales[idx])-1 for idx in scale_positions]
    return scale_notes


random.seed()

scales_map = load_scales()
bpm = int(input("BPM: "))
scale_id = input("Scale Id: ")
scales = scales_map[scale_id]
print(KEY_MESSAGE)
key = int(input("Key: "))
key_name = KEY_DICT[key]
pitch = input("Pitch(+/-): ")
if not pitch:
    pitch = 0
pitch_shift = int(pitch)
key += (pitch_shift * PITCH_RANGE)

lengths = fetch_sentence_lengths()
scale_notes = length_to_scale_notes(lengths, scales)
print(scale_notes)

mid = MidiFile()
track = MidiTrack()
mid.tracks.append(track)
track.append(MetaMessage('set_tempo', tempo=mido.bpm2tempo(bpm)))

for scale_note in scale_notes:
    rand_time = 480 * random.choice([1, 2, 4])
    rand_velocity = 64 + random.choice(list(range(-10, 11)))
    track.append(Message('note_on', note=key+scale_note, velocity=rand_velocity, time=0))
    track.append(Message('note_off', note=key+scale_note, time=rand_time))

filename = f"{key_name}_{bpm}_scaleId{scale_id}.mid"
mid.save(filename)