Python の正弦波で曲を演奏する

入り用があって Python の正弦波で曲を演奏するスクリプトをかきました。Python で正弦波を wav 出力する記事は他にもありますが、本スクリプトは幅広い音階、和音、複数パートに対応しています。なので色んな楽譜が演奏できると思います。そんなに Python の正弦波で演奏したいかはわかりません。

なお、以下のスクリプトは聞きやすさのためにやや倍音を重ねて & 減衰を入れているので実は正弦波ではないです。



## スクリプト
メヌエットを演奏します。
```python
import numpy as np
import IPython  # Jupyter で再生するモジュール用に要ります.
from scipy.io import wavfile  # wav 書き出しにつかっています. IPython のモジュールからもダウンロードできます.
import librosa  # wav にした後からピッチシフト (移調) したいときにつかいます.

def _coef(k):  # 鍵盤を k ずらしたとき周波数が何倍になるか (平均律)
    return 2.0 ** (k / 12.0)

pitch_coefs = {  # 音名と周波数の対応 (ただし「ラ」の周波数の何倍かで表記)
    'C': _coef(-9), 'C#': _coef(-8), 'Db': _coef(-8), 'D': _coef(-7),
    'D#': _coef(-6), 'Eb': _coef(-6), 'E': _coef(-5), 'F': _coef(-4),
    'F#': _coef(-3), 'Gb': _coef(-3), 'G': _coef(-2), 'G#': _coef(-1),
    'Ab': _coef(-1), 'A': 1., 'A#': _coef(1), 'Bb': _coef(1), 'B': _coef(2)}
freqs_A = [55.0, 110.0, 220.0, 440.0, 880.0, 1760.0, 3520]  # 「ラ」の周波数

def _get_freq(pitch):
    ''' 音名から周波数を取得します (C0 ～ B6 に対応).
    '''
    if pitch == '-':
        return 0.0
    return freqs_A[int(pitch[-1])] * pitch_coefs[pitch[:-1]]

def get_wave(notes, tempo, sampling_rate, bar=4.0, auftakt=0.0):
    ''' 音符列から音波を生成します.
        notes: 音符列 (シーケンスのイテラブル). 各シーケンスは [音名, 拍数, ] の形式にしてください.
               和音はカンマ区切りにしてください.
        tempo: テンポ. 1拍あたりの秒数で指定してください.
        sampling_rate: サンプリングレート. 1秒当たりこの数だけのサンプルを生成します.
        bar: 1小節内の拍数 (オプショナル) (サンプル数の端数の帳尻合わせにつかうだけです).
        auftact: 弱起の拍数 (オプショナル) (サンプル数の端数の帳尻合わせにつかうだけです).
    '''
    # 音を減衰させないと同音連続が聞きづらいので 1 秒で 1/e まで減衰する数列をつくっておきます (適当に20秒分).
    lambda_ = 1.0  # k 倍にすると減衰時間が 1/k になります.
    decay = np.exp(-1.0 * lambda_ * np.linspace(0., 20.0, int(sampling_rate * 20.0) + 1)[:-1])

    freqs = []
    y = None
    total = 0.0  # 経過拍数
    for note in notes:
        pitches = note[0]
        duration = tempo * note[1]
        total += note[1]
        t = np.linspace(0., duration, int(sampling_rate * duration) + 1)[:-1]
        y_ = None
        for i_pitch, pitch in enumerate(pitches.split(',')):  # 1音ずつ正弦波をつくります.
            f = _get_freq(pitch)
            if i_pitch == 0:
                freqs.append(f)
            y__ = np.sin(2.0 * np.pi * f * t)  # 正弦波です.
            for a in range(2, 4):  # 正弦波だけだとくぐもりと繋ぎが気になるので 2, 3 倍音をちょっと重ねます.
                y__ += 0.1 * np.sin(2.0 * np.pi * (a) * f * t)
            y__ *= decay[:t.shape[0]]  # 減衰
            y_ = y__ if (y_ is None) else y_ + y__
        y = y_ if (y is None) else np.concatenate([y, y_])

        # 小節の終わり目ならば帳尻合わせします (数値誤差でサンプル数がずれるのでパートごとの音波を重ねるとき対策).
        fraction, integer = np.modf((total - auftakt) / bar)
        if np.abs(fraction) < 1e-6:
            target = int(sampling_rate * tempo * total)
            if y.shape[0] < target:
                y = np.concatenate([y, np.zeros(target - y.shape[0])])

    # 最後にも帳尻合わせをします.
    target = int(sampling_rate * tempo * total)
    if y.shape[0] < target:
        y = np.concatenate([y, np.zeros(target - y.shape[0])])
    return y, freqs  # 入り用だったので各音符の周波数も返します.

# メヌエット ト長調を生成します.
# https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%8C%E3%82%A8%E3%83%83%E3%83%88_(%E3%83%9A%E3%83%84%E3%82%A9%E3%83%BC%E3%83%AB%E3%83%88)
bar = 3.0
auftakt = 1.0  # この曲に弱起はないですがいきなり始まると聞きづらいのでわざと1拍おきます.
tempo = 0.4
sampling_rate = 22050
notes0 = [  # 右手
    ('-', 1.0),  # ダミー弱起
    ('D5', 1.0), ('G4', 0.5), ('A4', 0.5), ('B4', 0.5), ('C5', 0.5), ('D5', 1.0), ('G4', 1.0), ('G4', 1.0),
    ('E5', 1.0), ('C5', 0.5), ('D5', 0.5), ('E5', 0.5), ('F#5', 0.5), ('G5', 1.0), ('G4', 1.0), ('G4', 1.0),
    ('C5', 1.0), ('D5', 0.5), ('C5', 0.5), ('B4', 0.5), ('A4', 0.5), ('B4', 1.0), ('C5', 0.5), ('B4', 0.5), ('A4', 0.5), ('G4', 0.5), 
    ('F#4', 1.0), ('G4', 0.5), ('A4', 0.5), ('B4', 0.5), ('G4', 0.5), ('A4', 3.0), 
]
notes1 = [  # 左手 (総拍数を右手とそろえてください).
    ('-', 1.0),  # ダミー弱起
    ('G3,B3,D4', 2.0), ('A3', 1.0), ('B3', 3.0),
    ('C4', 3.0), ('B3', 3.0),
    ('A3', 3.0), ('G3', 3.0),
    ('D4', 1.0), ('B3', 1.0), ('G3', 1.0), ('D4', 1.0), ('D3', 0.5), ('C4', 0.5), ('B3', 0.5), ('A3', 0.5),
]
y0, _ = get_wave(notes0, tempo, sampling_rate)  # 右手
y1, _ = get_wave(notes1, tempo, sampling_rate)  # 左手

# Jupyter 上で再生するときはこうします.
IPython.display.display(IPython.display.Audio(y0 + y1, rate=sampling_rate))

# wav に出力したいときはこうします.
wavfile.write('minuets.wav', sampling_rate, y0 + y1)

# ト長調からへ長調にするとき鍵盤 2 つ分左にします.
y_mod = librosa.effects.pitch_shift(y0 + y1, sr=sampling_rate, n_steps=-2)
y_mod = librosa.effects.time_stretch(y_mod, rate=1.25)  # テンポを 1.25 倍に速く.
IPython.display.display(IPython.display.Audio(y_mod, rate=sampling_rate))

# 再度読み込みます.
y, sr = librosa.load('minuets.wav')
print(y.dtype)  # float32 型として保存されています.
print(sr)  # サンプリングレート 22050 として保存されています.
```

## 類似の記事

最近の投稿であって趣旨が似ているものは以下がありました。

- [正弦波で「ビッグブリッジの死闘」を奏でてみた](https://qiita.com/a_h_0227/items/77d3615c3e150e17524e)
  - 矩形波 / 三角波 / 鋸波にも対応しています。それぞれ、ゲームのピコピコ音 / オルゴールのような音 / トランペットのような音がします。そもそも正弦波はくぐもったオルガン音がします。
  - 上記のスクリプトでは矩形波 / 三角波 / 鋸波を実装していないですがフーリエ級数で近づけることはできます。具体的に、「正弦波だけだとくぐもりと繋ぎが気になるので」といって 2, 3 倍音を加えている箇所で、n 倍音を 1 / n (鋸波)、n 倍音 1 / n^2 (三角波)、奇数倍音を 1 / n (矩形波) の振幅で加えると音色が変わります。

## 経緯

- 自力で Python で合成音声を歌わせるのに、歌詞の各文字と周波数の対応のリストが必要だったのですが、音名を周波数に変換するだけでなくバグがないか通して聞けるようにしたかったので上のスクリプトをかきました。
  - その用途では音符列の 3 番目に歌詞を格納しておくとよいかもしれないです。 
- 文字と周波数の対応リストができたら、後は 1 文字ずつその周波数で合成してつなげば何となく歌わせられると思います。
  - 私は VOICEVOX の pitchScale パラメータを変えながら音声合成し、波形をフーリエ変換して基本周波数が目標値に近いかどうか判定しましたが、ピークが複数あるときにどれが基本周波数であるのか判別する処理が自動化できていません。声質やソフトウェアにもよると思いますが基本周波数が最大ピークにならなくなる 50 音もよくあると思います。なので、自分の絶対音感で判定しました。やり方を教えてください。
  - librosa などで音声をピッチシフトすることもできますが、引き延ばしや圧縮は声質ごと変えるのであまりやらない方がいいと思います。どうしても近い周波数が合成できなかったときにはなるべく近い周波数からピッチアップかピッチダウンしますが、本来は基本周波数に応じたフォルマントの変化を学習して、この周波数成分はシフトせず保つなどしなければならないと思います。やり方を教えてください。