見出し画像

Pythonでサイン波を組み合わせて音楽を奏でる

こちらの書籍が非常に面白かったので、純音であるサイン波の組み合わせで音楽を奏でるプログラムをPythonで書いてみようと思います。

音響学に関しては「この書籍で初めて勉強している」程度のド素人であるため、万が一、間違ったことを書いてしまっていたら申し訳ございません。

基本的な手順

  • あらゆる音(=波形)は大小さまざまな複数のサイン波を寄せ集めて作ることができる。

  • 一つひとつのサイン波は一つの周波数(=音の高さ)に対応する。色んな周波数の成分を色んな大きさで組み合わせたのが「音」。

  • 音楽において、音の高さは音階と呼ばれるルールに従って定義される。一般的な音階は12平均律音階のため、このルールにしたがって周波数を決め、それぞれの音に対してタイミングと大きさを割り当てていけば音楽をゼロから作ることができる。

  • wavファイルとして書き込めるよう、信号を量子化(デジタル化)する。

Python環境について

  • Jupyter環境で実行しています。Google Colabを使えば環境構築不要で実行可能です。

  • Colabの実行環境である3.8系に合わせて型アノテーションを書いています(2022/1/8現在)。

  • 全体のコードはこちらから。そのままColabでも実行できます。

ライブラリは次を使っています。

from dataclasses import dataclass
from typing import List, Dict

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.io import wavfile

1. 純音・複合音

サイン波は、一つの周波数成分しか持たない「純音」である。サイン波は3つのパラメタを持つ次の式で表される。

$$
s(t) = a \sin (2\pi ft + \theta)
$$

$${a}$$は振幅、$${f}$$は周波数、$${\theta}$$は位相を表す。振幅は音の大きさ、周波数は音の高さに対応する。

あらゆる波形は大小さまざまなサイン波の寄せ集めである。複数のサイン波によって構成される波形の音を複合音と呼び、次のように定義する。

$$
s(t) = \sum_i a_i \sin(2\pi f_i t + \theta_i)
$$

あらゆる波形がサイン波の寄せ集めで表現できることの視覚的なイメージは、こちらの動画が非常に分かりやすかったです。

では、まずは純音と複合音の式をPythonで書いていきます。

サイン波の3つのパラメタをクラスで構造化したあと、与えられた時間$${t}$$の配列に対してサイン波を出力する関数$${s(t)}$$を作ります。また、複合音はサイン波のリストを与えて作るという形にしています。

@dataclass
class SineWave:
    f: float
    a: float = 1.
    theta: float = 0.

    def s(selft: np.ndarray) -> np.ndarray:
        return self.a * np.sin(2 * np.pi * self.f * t + self.theta)

@dataclass
class CompositeWave:
    waves: List[SineWave]

    def s(selft: np.ndarray) -> np.ndarray:
        return np.sum([wave.s(t) for wave in self.waves], axis=0)

さて、適当な周波数の組み合わせで作った波形を以下のように描画してみます。

t = np.linspace(0.0.001100)  # sec
t_ms = t * 1000.0  # msec

waves = [
    SineWave(f=1000, a=1),
    SineWave(f=3000, a=2, theta=np.pi / 2),
    SineWave(f=5000, a=0.5, theta=np.pi)
]

plt.figure(figsize=(102), dpi=200)
for wave in waves:
    plt.plot(t_ms, wave.s(t), label=f'$f: {wave.f}$ [Hz]')

plt.plot(t_ms, CompositeWave(waves).s(t), label='sum')
plt.xlabel('ms')
plt.legend()
plt.show()

2. サンプリング

では、次に音をwavファイルに書き込めるようにするための前処理をしていきます。

音はすべての時刻で連続的な値を取っている「アナログ信号」ですが、コンピュータで音を取り扱うために標本化量子化という二つのステップを行います。標本化は一定の時間間隔でアナログ信号を読み取ることで時間方向を離散化し、量子化は信号の振幅(大きさ)を一定のステップ数で離散化します。

2.1 標本化

まず、一般的なCD音源のサンプリング周波数は次の通りです。

# 一般的なサンプリングレート 44.1kHz
RATE = 44100

このサンプリング周波数で時刻tを生成すれば、1秒間あたり44100個の値をもつ配列が生成でき、これはすでに標本化された信号であるといえます。

duration = 1  # sec
t = np.linspace(0, duration, RATE * duration)
s = SineWave(f=1000, a=0.1).s(t)  # 44.1kHzで標本化された1000Hzのサイン波

2.2 量子化

次に、信号の振幅を一定のステップ数で区切ることで、振幅を整数のデータとして取り扱えるようにします。

音楽CDは量子化精度を16bitに設定しているらしいです。この場合、$${-32768}$$から$${+32767}$$まで合計で$${2^{16}=65536}$$段階のステップ幅で振幅が離散化されています。

こちらの処理をnumpyを使って書いていきます。処理としては0~1に信号が収まるように正規化した後、$${2^{16}}$$をかけてから四捨五入します。量子化の範囲内に信号が確実に収まるように振幅を打ち切るクリッピング処理をはさんでから、$${2^{15}=-32768}$$を引いて範囲内に収めています。

※なお、配列の小数点を丸める際によく使われるnp.roundは偶数丸めという処理で四捨五入と厳密には異なるので注意する必要があります。

# 0 ~ 1の範囲に信号を収める
def _normalize(s):
    return (s - np.min(s)) / (np.max(s) - np.min(s))

# 0.5足して整数部分を抽出 = 5切り上げの四捨五入
def _round(s):
    return (s + 0.5).astype(int)

# 量子化(16ビット)
def digitalize_16bit(s):
    s = _normalize(s) * 2 ** 16
    s = _round(s)
    s = np.clip(s, 02 ** 16 - 1)  # クリッピング
    s -= 2 ** 15
    return s.astype('int16')

2.3 フェード処理

音の先頭や末尾が突発的に変化する場合、耳障りなクリックノイズが聴こえてしまうそうです。そこで信号の先頭をフェードイン、末尾をフェードアウトさせる次の処理をはさみます。

# フェード処理
def add_fade(s, n_pre=400, n_post=400):
    s_ = s.copy()
    s_[:n_pre] *= np.arange(n_pre) / n_pre
    s_[-n_post:] *= np.arange(n_post)[::-1] / n_post
    return s_

2.4 無音区間の設定

クリックノイズを確実に取り除くために音の前後で無音区間を設定しておくことも有効だといいます。

# オフセット処理(無音区間の追加)
def add_offset(s, n_pre=400, n_post=400):
    s_ = np.append(np.zeros(n_pre), s)
    s_ = np.append(s_, np.zeros(n_post))
    return s_

では、これらの処理をまとめて1000Hzの純音をwavファイルに書き込んでみます。

duration = 1  # sec
t = np.linspace(0, duration, RATE * duration)
n_offset = int(RATE * 0.01)  # フェード処理・オフセット処理を施すサンプル数

wave = SineWave(f=1000, a=0.1)
s = wave.s(t)
s = add_fade(s, n_pre=n_offset, n_post=n_offset)
s = add_offset(s, n_pre=n_offset, n_post=n_offset)
s = digitalize_16bit(s)
fig, (ax1, ax2) = plt.subplots(12, figsize=(102), dpi=200)

# フェード処理・オフセット処理を確認するために先頭と末尾を描画
t_head, t_tail = 0.0250.025  # sec
n_head, n_tail = int(RATE * t_head), int(RATE * t_tail)

# 先頭
ax1.plot(t[:n_head], s[:n_head])
ax1.set_xlim(0, t_head)
ax1.set_ylim(np.iinfo(np.int16).min, np.iinfo(np.int16).max) # 量子化された信号が16-bitの範囲内にあることを確認

# 末尾
ax2.plot(t[-n_tail:], s[-n_tail:])
ax2.set_xlim(1 - t_tail, 1)
ax2.set_ylim(ax1.get_ylim())
ax2.set_yticks([])

plt.suptitle(f'pure tone: {wave.f} [Hz]', fontsize=15)
plt.tight_layout()
plt.show()
フェード処理・オフセット処理を確認するために先頭と末尾を描画
# wavファイルに書き込み
filename = f'ch2_{wave.f}Hz.wav'
wavfile.write(DATADIR + filename, RATE, s)

※ ↓ 再生時は音量にご注意ください(イヤホン推奨)。
時報みたいな音って、なんかこわいですよね… 家で再生していたら飼い猫がびっくりしてました。

3. 12平均律音階の実装

12平均律音階では、1オクターブ離れた音は周波数にして2倍になるという関係から、1オクターブを12分割して音名を割り当てたものです。基準となる周波数(440Hz=A4)を決め、その周波数に$${2^{1/12}}$$をかけていくことで半音ずつ上がっていくことができます。

ピアノで使われる88鍵と、MIDIで使われる128鍵の両方に対応できるよう一般化した音階を生成する関数を次のように作りました(インデックスのロジックが思ったより難しかった)。音階にも色々あるんですね〜。

def create_scale(n=88base=440., base_idx=48, start_note='A', start_octave=0):
    NAMES = ("C""C#""D""D#""E""F""F#""G""G#""A""A#""B")
    scale = {}

    for idx in range(n):
        idx_ = idx + NAMES.index(start_note)
        name = NAMES[idx_ % 12]
        octave = int(idx_ / 12) + start_octave
        scale[name + str(octave)] = base * 2 ** ((idx - base_idx) / 12)
        
    return scale
scale_88 = create_scale()  # default: Piano 88 keys
scale_128 = create_scale(n=128, base_idx=69, start_note='C', start_octave=-1)  # MIDI keys

df_88 = pd.DataFrame.from_dict(scale_88, orient='index', columns=['Hz'])
df_128 = pd.DataFrame.from_dict(scale_128, orient='index', columns=['Hz'])

display(df_88)
display(df_128)
左: 88鍵(Piano) 右: 128鍵(MIDI)

4. トラック

では、12平均律音階の望みの音が生成できるようになったので、あとは「楽譜」にしたがって各音のタイミングや長さ、大きさを指定して「トラック」を作れるようにします。譜表における段ごとにトラックを生成して、最後に複数のトラックを組み合わせて音楽を作ります。

まず、「一音」を定義するNoteクラスを作りました。このクラスでは、一音の「開始時刻 start」「高さ pitch」「大きさ gain」「長さ duration」を管理しています。

そして、Noteのリストと音階(scale)を引数にとって、トラックを生成するTrackクラスを作りました。トラックの信号は要素0の配列で初期化し、一音ずつ指定の位置にサイン波を加えていく、という処理にしています。

@dataclass
class Note:
    start: float  # sec
    pitch: str
    gain: float = 0.5
    duration: float = 1.  # sec

@dataclass
class Track:
    scale: Dict[str, float]
    notes: List[Note]

    def generate(self, duration, offset=0, rate=RATE):
        t = np.linspace(0, duration, int(rate * duration))
        s = np.zeros_like(t)

        for note in self.notes:
            idx_start = int((note.start + offset) * rate)
            idx_end = int((note.start + note.duration + offset) * rate)
            s_ = SineWave(f=self.scale[note.pitch], a=note.gain).s(t[idx_start:idx_end])
            s_ = add_fade(s_)  # フェード処理
            s[idx_start:idx_end] += s_
        return t, s

では、音のベタ打ちがちょっと大変ですが、書籍に載っている「カノン」の楽譜を打ち込んでいきます。右手のメロディを左手より少し大きめにしてみました。

tempo = 120
duration = 20 * (60 / tempo) + 2

track1 = Track(
    scale=scale_88,
    notes=[
        Note(2'E5'0.8),
        Note(3'D5'0.8),
        Note(4'C5'0.8),
        Note(5'B4'0.8),
        Note(6'A4'0.8),
        Note(7'G4'0.8),
        Note(8'A4'0.8),
        Note(9'B4'0.8)
    ]
)

track2 = Track(
    scale=scale_88,
    notes=[
        Note(2'C4'0.4),
        Note(3'G3'0.4),
        Note(4'A3'0.4),
        Note(5'E3'0.4),
        Note(6'F3'0.4),
        Note(7'C3'0.4),
        Note(8'F3'0.4),
        Note(9'G3'0.4),
    ]
)

t, s1 = track1.generate(duration)
t, s2 = track2.generate(duration)
s = s1 + s2

では、生成したカノンの波形を見てみましょう。全部を描画すると周波数が高すぎて潰れて見えないので、一定ステップごとに描画します。

offset = 2
step = 500

plt.figure(figsize=(103), dpi=200)

plt.plot(t[::step], s1[::step], label='track 1')
plt.plot(t[::step], s2[::step] + offset, label='track 2')
plt.plot(t[::step], s[::step] + offset * 2, label='mixed')

plt.xlabel('s')
plt.yticks([])

plt.legend()
plt.show()
カノンの波形

カノンをwavに書き込みます。

# 音量調整
s /= np.max(np.abs(s))
master_volume = 0.5
s *= master_volume
s = digitalize_16bit(s)

filename = 'ch3_canon.wav'
wavfile.write(DATADIR + filename, RATE, s)

味気のない音ですが、ちゃんとカノンが流れている!頭ではサイン波の寄せ集めと聞いていても、実際に音楽になるとちょっと感動してしまいます。

5. おわりに

音響学のほんの基礎の部分だと思うのですが、Pythonを使ってサイン波からシンプルな音楽を生成できることを体験しました。

また、本記事で実装した部分は書籍の導入部分に過ぎず、以降はシンセサイザやエフェクタ、弦楽器や鍵盤楽器に至るまで「音の作り方」に関する解説が盛りだくさんでした。こちらの方も追々実装しながら遊んでみたいと思っています。

最後まで読んでくださりありがとうございました。

この記事が参加している募集

学問への愛を語ろう

この記事が気に入ったらサポートをしてみませんか?