CircuitPythonでDJ Controllerを作る

本日のポイント

  • Adafruit MACROPAD RP2040の各キーに音源を割り当てて同時再生する
  • スピーカーを接続して、いい音でドスドス鳴らす
  • CircuitPythonっていいよね!

DJコントローラーとは?

例えばこういうモノです。
一つ一つのボタンに音源が割り振られていて、押すと音源が再生されます。

TRAKTOR KONTROL F1-NEW, NATIVE INSTRUMENTS

ユーザー要求(私のやりたいこと)

  • 音源はマイコンのストレージ内に保持し、MIDIや外部の音源に依存しない。
  • キーを押すとwav音源を再生する
  • ドラムなど、ループする音源はキーを一度押せばずっとループ再生する。もう一度押すと再生をストップする。
  • ボーカルなどは、キーは押している時だけ再生し、キーを離すと再生をストップする。
  • 再生中はキーをカラフルに光らせる

マイコンボードの選定

Adafruit MACROPAD RP2040はMX互換キースイッチを使用していて、きっちりした打鍵感があるので、正確なタイミングで音源を再生可能な高い操作性を実現できると考えました。
また外部音源に依存せずMACROPAD内で完結したかったため、8MBのストレージ容量はありがたかったです。

プログラミング言語

いつもどおりCircuitPythonです。
MACROPAD RP2040に対応したCircuitPythonは2021年9月11日現在、CircuitPython 7.0.0-rc.1です。現在の最新のバージョンをインストールするようにしてください。
CircuitPython 7.x.xに対応したライブラリバンドルの中には、 MACROPAD RP2040専用のadafruit_macropadというライブラリがあって基本的な使い方であれば簡単にプログラミングできて大変便利です。ありがとう、Adafruit!

材料

いくつかの問題点

まずはこちらをご覧ください…。

1. 音源の同時再生問題

adafruit_macropadライブラリの中の「play_file」関数が使えそうだったんですが、1つの音源の再生中に他の処理が出来ない(いわゆるblockingな関数だった)ため、これでは難しそうでした。
また、 adafruit_macropadライブラリのコードを読むと、音声の出力先が内蔵スピーカーに固定されていました。
そこで、 adafruit_macropadライブラリを使わない実装方法を検討しました。

音源の同時再生にはaudiomixerライブラリを使うことにします。

2. 音質問題

MACROPADに搭載されているスピーカー(圧電ブザー)の音質はさほど良くないので、何か別の方法を考える必要がありました。
MACROPADから外部に接続できる唯一の端子としてSTEMMA QTコネクタがありますので、そこにD級アンプとスピーカーを接続することを考えます。(アンプやスピーカーの選定には全く知識もこだわりもないため、家にあるものを使いました。)

音源ファイル

今回はAudio Vatが無料サンプルとして配布しているTrance Vocal Sample Pack Freebieを使用しました。(ユーザー登録が必要ですが、品質の高い音源が揃っていますのでオススメです。)
トランスの楽器のパートごとに音源が分かれていて使いやすいです。音質も良く、何よりこのボーカルが私好みです。

CircuitPythonのaudiobusioで扱えるようにするため、各パートの音源(WAVファイル)を16,000bpsに変換します。
また、今回はスピーカーが1つしかないのでチャンネルをモノラルにします。
元の音源だと尺が長すぎたり、音源の頭に無音が入っていたりして使いづらい物があったのでいくつか調整をしています。(今回の開発の労力の1/3ぐらいがこの音源調整でした。)

(Audio Vatさんの規約に違反してしまうため、私が使用した音源ファイルの公開は差し控えさせていただきます。)

スピーカーを接続する

Adafruit MACROPAD RP2040にはスピーカー出力が無いため、唯一外部デバイスと接続できるSTEMMA QTコネクタ(I2C通信のためのコネクタ)のピンを利用します。
以下のように接続してください。

コード

import board
import time
# 入力関連
import keypad
import digitalio
# NeoPixel関連
import neopixel
import adafruit_fancyled.adafruit_fancyled as fancy
# オーディオ関連
import audiopwmio
import audiocore
import audiomixer

"""
digitalio.DigitalInOut(board.SPEAKER_SHUTDOWN)で有効化したスピーカーに対して
adafruit_macropadライブラリが共存できないため、代わりにkeypadライブラリを使用する
"""

#キー入力
key_pins = [board.KEY1, board.KEY2, board.KEY3,
            board.KEY4, board.KEY5, board.KEY6,
            board.KEY7, board.KEY8, board.KEY9,
            board.KEY10, board.KEY11, board.KEY12
           ]
keys = keypad.Keys(key_pins, value_when_pressed=False, pull=True)

# NeoPixel設定
pixels = neopixel.NeoPixel(board.NEOPIXEL, 12, auto_write=True)

# CIRCUTPYドライブに保存したWAVファイルを読み込む
sample = [0] * 12
# Drum Loops
sample[0] = audiocore.WaveFile(open("FAVTV Kick Loop138bpm.wav_16K.wav", "rb"))
sample[1] = audiocore.WaveFile(open("FAVTV Hi Hat Loop138bpm.wav_16K.wav", "rb"))
sample[2] = audiocore.WaveFile(open("FAVTV Clap Loop138bpm.wav_16K.wav", "rb"))
# Leads
sample[3] = audiocore.WaveFile(open("FAVTV Arp Melody 138bpm Key A Major_16K_half.wav", "rb"))
sample[4] = audiocore.WaveFile(open("FAVTV Arp Melody 2 138bpm Key A Major_16K.wav", "rb"))
sample[5] = audiocore.WaveFile(open("FAVTV Piano SC 138bpm Key A Major_16K.wav", "rb"))
# Vocals
sample[6] = audiocore.WaveFile(open("FAVTV Female Wet Chop Vocals 138bpm Key A Major_16K.wav", "rb"))
sample[7] = audiocore.WaveFile(open("FAVTV Female Wet Vocals 138bpm Key A Major_16K.wav", "rb"))
sample[8] = audiocore.WaveFile(open("FAVTV Male Wet Vocals 138bpm Key A Major_16K.wav", "rb"))
# Others
sample[9] = audiocore.WaveFile(open("FAVTV Kick Roll138bpm_16K.wav", "rb"))
sample[10] = audiocore.WaveFile(open("FAVTV Pads 138bpm Key A Major_16K.wav", "rb"))

# speakerを初期化
speaker_en = digitalio.DigitalInOut(board.SPEAKER_SHUTDOWN)
speaker_en.switch_to_output(value=False)
speaker_en.value = True  # ***重要:これがないとスピーカーが有効化されない***

# audioオブジェクト作成
# 以下いずれかを使用してください
#audio = audiopwmio.PWMAudioOut(board.SPEAKER)  # Onboard speaker
audio = audiopwmio.PWMAudioOut(board.SDA)  # Audio amplifier connected to STEMMA QT SDA(3pin)

# mixerオブジェクト作成
mixer = audiomixer.Mixer(voice_count = 12,
                         sample_rate = 16000,
                         channel_count = 1,
                         bits_per_sample = 16,
                         samples_signed = True)

# mixerオン
audio.play(mixer)

# Mixerの各トラックの音量を設定
# 今回は全トラックの音量を0.1にした
lvl = 0.1  # min 0 --- 1.0 max
for v_no in range(11):
    mixer.voice[v_no].level = lvl

# Drum Loopの状態変数
DL_state = [False] * 12

# メインループ
"""
keys.events.getで取得できるのは以下のような文字列
<Event: key_number 9 pressed>
<Event: key_number 9 released>
<Event: key_number 10 pressed>
<Event: key_number 10 released>
ここから、キー番号と押したか離したかを取得
"""

while True:
    k = keys.events.get()
    if k != None:
        k_no = int(str(k)[19:21])  # 0 - 11
        if str(k)[21] != " ":
            k_ev = str(k)[21:24]  #0 - 9の場合'pre'(ss) or 'rel'(ease))
        else:
            k_ev = str(k)[22:25]  #10 - 11の場合'pre'(ss) or 'rel'(ease))

        # Drum Loopのように、次に同じキーを押すまでループするトラックの処理
        if k_no in [0, 1, 2, 3, 4, 5, 10]:
            if k_ev == "pre":
                if DL_state[k_no] == False:
                    mixer.voice[k_no].play(sample[k_no], loop=True)
                    DL_state[k_no] = True
                    pixels[k_no] = fancy.CHSV((k_no)/12, 1.0, 1.0).pack()
                else:
                    mixer.stop_voice(k_no)
                    DL_state[k_no] = False
                    pixels[k_no] = (0, 0, 0)

        # ボーカルやエフェクトのようにキーを押している間だけ再生するトラックの処理
        elif k_no in [6, 7, 8, 9]:
            if k_ev == "pre":
                mixer.voice[k_no].play(sample[k_no], loop=True)
                pixels[k_no] = fancy.CHSV((k_no)/12, 1.0, 1.0).pack()
            if k_ev == "rel":
                mixer.stop_voice(k_no)
                pixels[k_no] = (0, 0, 0)

        # 11番(一番右下のキー)を押したときは、audioオブジェクトを破棄する。
        # これは、ファイルの書き込み時にスピーカーからかなりうるさいノイズが発生するため
        # 書き込み前に音を出ないようにするため
        elif k_no == 11:
            audio.deinit()

コードの解説

import board
import time
# 入力関連
import keypad
import digitalio
# NeoPixel関連
import neopixel
import adafruit_fancyled.adafruit_fancyled as fancy
# オーディオ関連
import audiopwmio
import audiocore
import audiomixer

今回使うライブラリはこちらです。すでにCircuitPythonのコアに組み込まれているライブラリがほとんどですが、neopixel.mpyとadafruit_fancyledはライブラリバンドルからMACROPAD RP2040のlibフォルダにコピーしておいてください。

#キー入力
key_pins = [board.KEY1, board.KEY2, board.KEY3,
            board.KEY4, board.KEY5, board.KEY6,
            board.KEY7, board.KEY8, board.KEY9,
            board.KEY10, board.KEY11, board.KEY12
           ]
keys = keypad.Keys(key_pins, value_when_pressed=False, pull=True)

keypadライブラリで使うためのキーのピン情報をkey_pinsというリストに格納します。
次に、keypad.keys(<ピン情報のリスト>, value_when_pressed=False, pull=True)でキーのオブジェクトを作成し、keysという名前にしました。

# NeoPixel設定
pixels = neopixel.NeoPixel(board.NEOPIXEL, 12, auto_write=True)

ここでは、各キーの下に設置されているRGB LEDのNeoPixelを使うためのオブジェクトを作成して、pixelsという名前にしました。

# CIRCUTPYドライブに保存したWAVファイルを読み込む
sample = [0] * 12
# Drum Loops
sample[0] = audiocore.WaveFile(open("FAVTV Kick Loop138bpm.wav_16K.wav", "rb"))
sample[1] = audiocore.WaveFile(open("FAVTV Hi Hat Loop138bpm.wav_16K.wav", "rb"))
sample[2] = audiocore.WaveFile(open("FAVTV Clap Loop138bpm.wav_16K.wav", "rb"))
# Leads
sample[3] = audiocore.WaveFile(open("FAVTV Arp Melody 138bpm Key A Major_16K_half.wav", "rb"))
sample[4] = audiocore.WaveFile(open("FAVTV Arp Melody 2 138bpm Key A Major_16K.wav", "rb"))
sample[5] = audiocore.WaveFile(open("FAVTV Piano SC 138bpm Key A Major_16K.wav", "rb"))
# Vocals
sample[6] = audiocore.WaveFile(open("FAVTV Female Wet Chop Vocals 138bpm Key A Major_16K.wav", "rb"))
sample[7] = audiocore.WaveFile(open("FAVTV Female Wet Vocals 138bpm Key A Major_16K.wav", "rb"))
sample[8] = audiocore.WaveFile(open("FAVTV Male Wet Vocals 138bpm Key A Major_16K.wav", "rb"))
# Others
sample[9] = audiocore.WaveFile(open("FAVTV Kick Roll138bpm_16K.wav", "rb"))
sample[10] = audiocore.WaveFile(open("FAVTV Pads 138bpm Key A Major_16K.wav", "rb"))

ここでは、audiocore.WaveFileをつかってCIRCUITPYドライブ直下に置いたWAVファイルを読み込み、sampleというリストに一つづつ定義しています。

# speakerを初期化
speaker_en = digitalio.DigitalInOut(board.SPEAKER_SHUTDOWN)
speaker_en.switch_to_output(value=False)
speaker_en.value = True  # ***重要:これがないとスピーカーが有効化されない***

# audioオブジェクト作成
# 以下いずれかを使用してください
#audio = audiopwmio.PWMAudioOut(board.SPEAKER)  # Onboard speaker
audio = audiopwmio.PWMAudioOut(board.SDA)  # Audio amplifier connected to STEMMA QT SDA(3pin)

ここが今回一番重要なポイントです。
MACROPAD RP2040に搭載されたスピーカー(圧電ブザー)を使用するための設定として、上の3行を設定しています。

次に、audiopwmio.PWMAudioOutを使って、音声信号の出力先を指定しています。本体のスピーカーを使いたいときは、以下のようにコメントアウトを変更してください。

# 以下いずれかを使用してください
audio = audiopwmio.PWMAudioOut(board.SPEAKER)  # Onboard speaker
#audio = audiopwmio.PWMAudioOut(board.SDA)  # Audio amplifier connected to STEMMA QT SDA(3pin)

# mixerオブジェクト作成
mixer = audiomixer.Mixer(voice_count = 12,
                         sample_rate = 16000,
                         channel_count = 1,
                         bits_per_sample = 16,
                         samples_signed = True)

# mixerオン
audio.play(mixer)

# Mixerの各トラックの音量を設定
# 今回は全トラックの音量を0.1にした
lvl = 0.1  # min 0 --- 1.0 max
for v_no in range(11):
    mixer.voice[v_no].level = lvl

Mixerオブジェクトを作って、複数の音源を同時に再生できるようにします。
audio.play(mixer)でmixerオブジェクトの音声の出力先をaudioで定義した先(内蔵スピーカーまたはSTEMMA QTコネクタに接続したスピーカー)に設定します。

Mixerの音量はデフォルトではかなり大きいため、mixerの各音源の音量を下げておきます。今回は0.1にしました。

# Drum Loopの状態変数
DL_state = [False] * 12

今回は、ドラムループは一度キーを押せば繰り返し再生し続けたいので、キーを1回押すとTrue、もう一回押すとFalseを格納するためのDL_stateリストを用意しておきます。

while True:
    k = keys.events.get()
    if k != None:
        k_no = int(str(k)[19:21])  # 0 - 11
        if str(k)[21] != " ":
            k_ev = str(k)[21:24]  #0 - 9の場合'pre'(ss) or 'rel'(ease))
        else:
            k_ev = str(k)[22:25]  #10 - 11の場合'pre'(ss) or 'rel'(ease))

ここからがメインループです。
While True:で無限ループに入ります。

keys(keypadライブラリのKeysで作っておいたオブジェクト)の状態を取得するため、keys.events.get()を変数kに格納します。
キーを押すと、変数kには以下のような値が格納されます。ここから、スライスを使ってキーの番号とpressedまたはreleasedの最初の3文字を取り出します。
注意が必要なのは、キーの番号が1桁の時と2桁(10または11)の時でスライスする位置がずれる点です。

<Event: key_number 9 pressed>
<Event: key_number 9 released>
<Event: key_number 10 pressed>
<Event: key_number 10 released>

        # Drum Loopのように、次に同じキーを押すまでループするトラックの処理
        if k_no in [0, 1, 2, 3, 4, 5, 10]:
            if k_ev == "pre":
                if DL_state[k_no] == False:
                    mixer.voice[k_no].play(sample[k_no], loop=True)
                    DL_state[k_no] = True
                    pixels[k_no] = fancy.CHSV((k_no)/12, 1.0, 1.0).pack()
                else:
                    mixer.stop_voice(k_no)
                    DL_state[k_no] = False
                    pixels[k_no] = (0, 0, 0)

キーの番号(k_no)が0, 1, 2, 3, 4, 5または10の場合は一度キーを押したらずっとループ再生します。
状態変数DL_stateがFalse(=現在は再生していない)場合、mixer.voice[<キー番号>].playでサンプルの再生を開始し、 状態変数DL_state をTrueに書き換えます。
また、pixelsを使ってLEDを光らせます。LEDの色は、HSVカラーの色相(Hue)を12等分してカラフルな表示にしてあります。

状態変数DL_stateがFalseでない(再生中)場合は、再生を止めます。また、 状態変数DL_stateをFalseに戻し、Neopixelを消灯します。

        # ボーカルやエフェクトのようにキーを押している間だけ再生するトラックの処理
        elif k_no in [6, 7, 8, 9]:
            if k_ev == "pre":
                mixer.voice[k_no].play(sample[k_no], loop=True)
                pixels[k_no] = fancy.CHSV((k_no)/12, 1.0, 1.0).pack()
            if k_ev == "rel":
                mixer.stop_voice(k_no)
                pixels[k_no] = (0, 0, 0)

if k_no in…の続きです。
キーの番号(k_no)が 6, 7, 8または9の場合は、キーを押している時にだけ音源を再生します。
そのため、キーのイベントがrel(eased)の状態もif文でチェックしています。

        # 11番(一番右下のキー)を押したときは、audioオブジェクトを破棄する。
        # これは、ファイルの書き込み時にスピーカーからかなりうるさいノイズが発生するため
        # 書き込み前に音を出ないようにするため
        elif k_no == 11:
            audio.deinit()

最後は、11番(一番右下)キーです。MACROPADにコードを書き込んでいるときにスピーカーからかなりうるさいノイズが出ます。そのため、プログラムを書き換える前にaudioオブジェクトを破棄してノイズを発生させないようにしています。

最後に

いかがだったでしょうか?好きな音源を使ってDJ気分を楽しんでみてくださいね。

今回は、ロータリーエンコーダやOLEDディスプレイを使っていなかったので、今後はこれらも同時に使えるような実装をしてみようと思います。

Follow me on Twitter