生成した音信号を気軽に鳴らせるシステムが欲しくなり、結果的にシンセサイザもどきを作ってみました。
MIDIを扱うと面倒なので、Numpy/Scipyで生成した音信号を直接オーディオ出力できるような構造になっています。
また、一応シンセサイザっぽい見た目なので、PyQt5を使ってクリック&キーボード入力可能な鍵盤を描画し、実際に音を鳴らせるようにもしてみました。
設計
今回はプロトタイプなので、VCF,VCA,といった機能すら持たない、オシレータだけのシンセサイザもどきを作ります。
そのため、必要な入力情報としては、音高と発音タイミング情報になります。従って、以下のようなモジュールを作れば実現できます。
- 音高・タイミングを入力できるインターフェース
- 音高に従って信号(サイン波/のこぎり波)を生成
- 生成したオーディオ出力する
今回の実装では、それぞれ以下のモジュールが対応しています。
- PyQt5
- Numpy, Scipy
- PyAudio
さらに、オーディオ出力中もインターフェースでの操作を可能にするため、threading
を用いて並列処理をしています。
実装の要点
PyQt5による簡易シンセ鍵盤の作成
PyQtの情報はWeb上には少ないこともあり、以下をベースに鍵盤を作りました。
Python: PyQt5のQPushButtonで作る簡易ピアノ鍵盤 - Wizard Notes
以下、実装のポイントを列挙します。
- ボタンを並べたいときは、
QGridLayout
が便利 buttonClicked()
に、クリック時の処理をするコールバック関数を渡す- 引数があるときは、
functools.partial
で対応
- 引数があるときは、
- 終了時の処理は
QWidget
のcloseEvent()
をオーバーライド
Numpy, Scipyによる信号生成
numpyp.sin
とscipy.signal.sawtooth
を使います。
注意点として、PyAudioのストリームに1024サンプル程度のバッファごとに信号を送る必要があります。 そのため、現在のバッファ用信号作成の際には、前のバッファでの信号の位相を保持しておく必要があります。
今回のコードでは、愚直ですが、バッファサイズをオフセットとしています。ただし、self.period = n_chunk * rate
となったとき、位相は0となるため、オフセットself.offset
リセットしています*1。
def out(self): x = np.arange(self.offset, self.offset + self.n_chunk) chunk = self.gain * self.generator(self.pi2_t0 * x) self.offset += self.n_chunk if self.offset == self.period: self.offset = 0 return chunk
なお、今回の実装では、周波数ごとにオシレータを作っています。イメージとしては、各鍵盤に対してオシレータが存在する感じです。
PyAudioによるオーディオ出力
オーディオ入出力のみの実装については、以下の記事が参考になると思います。
注意点として、オーディオ出力は一つだけ開き、アクティブなオシレータが生成した信号を全て加算したバッファをstream.write()
に投げます。
def render(self): while self.stream.is_active(): chunk = np.zeros(self.n_chunk) for osc in self.oscillators: if osc.is_run(): chunk += osc.out() self.stream.write(chunk.astype(np.float32).tostring())
また、ストリーム書き込み中もGUIは操作できるようにしたいので、threading
を使って並列処理します。
t = threading.Thread(target=self.render) t.start()
今回の実装では、この並列処理は投げっぱなしです。そのため、オシレータが全て非アクティブのときも、要素がゼロのみの信号をストリームに書き込んでいます。
実装
補足
今回のPyQt5のキーボードは QPushButton を使っているので、クリックしている間は鳴らすというような一般的な鍵盤動作には向いていないと思います。
なので、PyQt5で真面目に演奏できる鍵盤を作ろうとすると結構大変なので、以下のものを採用するのはありだと思います。
*1:intが16-bitだとこの手法は厳しい