icon

Pythonでチップチューン音楽を作る

公開: 2023-11-23 / 最終更新: 2025-04-22
Python音楽チップチューン

Pythonで音声波形を操作して、チップチューン音楽、いわゆる8bit音楽をつくっていきます。

チップチューン

ファミコンなどに内蔵されている、制約の多い音源チップによって作られる音楽のことをチップチューンと呼びます。ファミコンのcpuが8bitであったことから、8bit音楽とも呼ばれます。

ファミコンではPSGという種類の音源チップが使われていました。PSG音源では、いくつかの主要な音を組み合わせて音を作り出します。ファミコンでは、次の3種類の音を組み合わせて音を作り出しています。

矩形波

Square Wave

くけいはたんけいはじゃないよ)。矩形は長方形という意味です。以下のような波で表します。

Loading image...

こんな音です。

矩形波ではデューティー比(疎密の比率)が1:1となっていますが、当然この比率が変わった音も存在します。それらを含めて一般的にパルス波と呼んだりもします。

Loading image...

三角波

Triangle Wave

以下のような三角形の波です。

Loading image...

三角形の形を変えた音も存在します。それらも三角波と呼びます。中でも直角三角形を連ねた以下のような波はノコギリ波 (Sawtooth Wave) と呼ばれます。

Loading image...

ホワイトノイズ

White Noise

Official髭男dismの楽曲: Official髭男dism - ホワイトノイズ [Official Video] - YouTube
というのは冗談で、ザーっというノイズ音。正規分布や一様分布に従ったサンプルを生成することで作れます。

Loading image...

Pythonで音を作る

実際に波形を作ってみましょう。

import numpy as np
import matplotlib.pyplot as plt

まずはsin波を作ります。最も基本的な音です。純音とも呼ばれます。

まず以下の値を設定します。

  • sr: サンプリングレート
  • sec: 秒数
  • freq: 周波数
sr = 44100
sec = 1.
freq = 440.

次に、時間軸(0からsecまでを1/srずつ刻む配列)を作ります。

t = np.arange(0, sec, 1/sr)

最後に、指定した周波数で振動する(1秒あたりfreq回振動する)sin波を作ります。

y = np.sin(2 * np.pi * freq * t)
plt.figure(figsize=(8, 3))
plt.plot(t, y)
plt.xlim(0, 0.02)

Loading image...

きれいな波ができました。これを音声ファイルに変換してみましょう。

import soundfile as sf
sf.write('sin.wav', y, sr)

こんな音です。

ちなみに、IPython Notebookでは配列を直接音として埋め込むことができます。

import IPython.display as ipd
ipd.Audio(y, rate=sr)

さて、他の波形も作ってみましょう。といっても、関数を変えるだけです。

scipy.signalに主要な波形が実装されているので、それを使います。

import scipy as sp
square = sp.signal.square(2 * np.pi * freq * t) # 矩形波
triangle = sp.signal.sawtooth(2 * np.pi * freq * t, width=0.5) # 三角波

これで完了です。先のものと同じ波形。

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 4))
ax1.plot(t, square, label='square')
ax1.set_xlim(0, 0.02)
ax1.legend()
ax2.plot(t, triangle, label='triangle')
ax2.set_xlim(0, 0.02)
ax2.legend()
fig.tight_layout()

Loading image...

音も先で示した通りです。

Pythonで音楽を作る

ではいよいよ音楽を作っていきます。

先ほどは音の高さを周波数で指定していましたが、それらを一つ一つ指定するのは面倒です。そこで、音階を数値で指定することとします。
音階にはMIDIノートナンバーを割り当てます。半音上がるごとに1ずつ増えていく整数値で、例えばC4(ピアノでいう真ん中のド)は60です。

次に、音階と周波数の対応を定義します。ここでは基準としてA4を440Hzとします。

ある音の周波数を2倍にすると、それは1オクターブ上の音になります。オクターブという関係はここから定義されています。この関係を利用して、音階と周波数の対応を次のように定義できます。

freqn=440×2n6912freq_n = 440 \times 2^{\frac{n-69}{12}}

nnはMIDIノートナンバーです。440は基準としたA4の周波数、69はA4のMIDIノートナンバー、12は1オクターブの音階の数です。
実装して確認してみましょう。

def get_freq(n):
    return 440 * 2 ** ((n-69)/12)
 
n_A4 = 69
n_A5 = 69 + 12
print(get_freq(n_A4))
print(get_freq(n_A5))
440.0
880.0

ちゃんとfreqn:freqn+12=1:2freq_n:freq_{n+12}=1:2になりましたね。

では、音階を適当に入力してメロディを作ってみましょう。

melody = [60, 62, 64, 65, 67]
sec = 1.
sr = 44100
t = np.arange(0, sec, 1/sr)
y = np.array([])
for n in melody:
    freq = get_freq(n)
    y = np.append(y, np.sin(2 * np.pi * freq * t))
ipd.Audio(y, rate=sr)

できましたね。

別のメロディも作ってみましょう。音とその長さをタプルで与えます。また波形も変えてみます。

melody = [
    (79, 0.3), (75, 0.3), (70, 0.3), (75, 0.3), (77, 0.3), (82, 0.6),
    (70, 0.3), (77, 0.3), (79, 0.3), (77, 0.3), (70, 0.3), (75, 0.6)
]
sr = 44100
y = np.array([])
for n, sec in melody:
    freq = get_freq(n)
    t = np.arange(0, sec, 1/sr)
    y = np.append(y, sp.signal.square(2 * np.pi * freq * t))
 
ipd.Audio(y, rate=sr)

聞き慣れた音楽が出来上がりました。

次は和音を作ってみましょう。和音とは複数の音が同時に鳴る音です。単純に複数の波を足すだけで作れます。

sr = 44100
sec = 1.
n1, n2, n3 = 60, 64, 67
t = np.arange(0, sec, 1/sr)
y = np.zeros_like(t)
for n in [n1, n2, n3]:
    freq = get_freq(n)
    y += np.sin(2 * np.pi * freq * t)
 
plt.figure(figsize=(8, 3))
plt.plot(t, y)
plt.xlim(0, 0.05)
ipd.Audio(y, rate=sr)

Loading image...

Cメジャーの和音が作れました。

Pythonでチップチューンを作る

では、これまでの内容を活用してチップチューン音楽を作っていきます。

まずは曲を打ち込むのですが、先ほどのようにMIDIノートナンバーと長さを手動で入力するのは面倒なので、MIDIファイルを読み込めるようにします。music21というライブラリを使います。

import music21 as m21

では作っていきましょう。ドラクエの序曲を打ち込みました。

まずはMIDIの読み込みといくつかの設定です。ファミコンに倣い、2つの矩形波と1つの三角波を使います。

path = 'midi/jokyoku.mid'
midi = m21.converter.parse(path)
sr = 44100
bpm = 120
waveforms = [
    sp.signal.square, # 矩形波
    sp.signal.square, # 矩形波
    lambda t: sp.signal.sawtooth(t, width=0.5), # 三角波
]

次に音を作っていきます。

tracks = []
for part, waveform in zip(midi[1:], waveforms): # metadata以外の全トラック
    y_track = np.array([])
    for event in part.flatten().notesAndRests: # トラック内の音符と休符
        ql = event.quarterLength # 長さ (4分音符)
        sec = 60 / bpm * ql # 長さ (秒)
        t = np.arange(0, sec, 1/sr) # 時間軸
 
        if isinstance(event, m21.note.Rest): # 休符
            y_note = np.zeros_like(t) # 音
        else: # 音符
            y_note = np.zeros_like(t)
            for pitch in event.pitches: # 和音を構成する全ての音
                freq = get_freq(pitch.midi) # 周波数
                # freq = pitch.frequency # これでもいい
                y_note += waveform(2 * np.pi * freq * t) # 音
 
        y_track = np.append(y_track, y_note) # 音を追加
    tracks.append(y_track) # トラックを追加
 
# 全部足す
y = np.zeros_like(max(tracks, key=len))
for y_track in tracks:
    y[:len(y_track)] += y_track
 
ipd.Audio(y, rate=sr)

できました。スピーカーによってはベースの音(三角波)が小さく感じるかもしれません。

別の曲も作ってみましょう。

ここでmunotesというライブラリを導入します。音符の波形を簡単に扱えるライブラリです。私が作りました。

import munotes as mn

こんな感じで音を生成できます。

note = mn.Note("C4")
note.play("square")

Loading image...

では作っていきましょう。今度はポケモン金銀のチャンピオン戦です。

今回はエンベロープを指定してみます。エンベロープとは音の立ち上がりや減衰の形を表すものです。

envelope_square = mn.Envelope(
    attack=0.01, decay=0.1, sustain=0.5, release=0.3,
    trans_orders={
        'decay': 2,
        'release': 2,
    }
)
note = mn.Note("C4")
y = note.render(envelope=envelope_square)
plt.figure(figsize=(8, 3))
plt.plot(y)

Loading image...

できました。こんな音です。

音がフェードアウトしている部分は分かりやすいと思います。
これを矩形波に適用して音を作ります。

path = 'midi/pokemon.mid'
bpm = 195
 
waveforms = ["square", "square", "triangle"]
envelopes = [
    envelope_square,
    envelope_square,
    mn.Envelope(attack=0.01, release=0.02)
]
amps = [1., 0.5, 1.]
midi = m21.converter.parse(path)
stream = mn.Stream([], unit='ql', bpm=bpm)
for part, waveform, envelope, amp in zip(midi[1:], waveforms, envelopes, amps):
    track = mn.Track([], waveform=waveform, envelope=envelope, amp=amp)
    for event in part.flatten().notesAndRests:
        ql = event.quarterLength
        if isinstance(event, m21.note.Rest):
            track.append(mn.Rest(duration=ql))
        else:
            notes = mn.Notes(
                [note.midi for note in event.pitches],
                duration=ql
            )
            track.append(notes)
    stream.append(track)
 
stream.play()

コードが少しシンプルになりましたね。音もいい感じです。

オワリ

以上です。ノイズとかも足していきたいですね。

ソースコードや使用したMIDIファイルはこちら: misya11p/python-chiptune