Glidetone制作記
この記事は総合制作集団「outré」のアドカレ企画24日目の記事です。
はじめまして。大学でデザインを学んでいるへだると申します。デザイン学生でありながら、モックアップの提案で終わらずに動くものを作りたいという思想があり、かなり工学に近い立場になっています。
今月、大学の課題(ほぼ自由制作みたいな課題が定期的に出ます)の中で、「鍵盤入力に応じて自動でチューニングが変わることで音程を演奏できる楽器」を作りました。
わずか2週間という制作期間で高速でプロトタイピングを回す開発体験ができたので、備忘録を兼ねて知見を共有します。Arduino電子工作、制御工学、3Dプリント、木工など多岐にわたる内容なので1記事にまとめるのも適切ではない気もしますが、分割してもおもろくないのでこのままいきます……。せめて読みやすくするために分野ごとに分けて記述します。おおよそ時系列ではありますが、実際はどこかが詰まっている間にほかの作業を同時並行したりしているので、実際の時系列とは前後が入れ替わっている部分もあります。
構想
別プロジェクトで「サーボを使って糸を引っ張って動かす平行移動アクチュエータ」を作っていたとき、その動きの中で糸の張力が変化し、はじくと音程が変わっているのに気づき、これを使って楽器を作ろうと思い立ちました。
しかし、今回の用途にサーボは適していません。サーボモーターは出せる減速比の選択肢が少ないうえ、楽器に使うにしては駆動音がうるさすぎます。それに、サーボの角度と張力の関係は弦を交換するたびに代わってしまうため、チューニングが厄介です。そこで今回はDCモーターを使い、出ている音を測定し周波数を元にフィードバックする構成にします。エンコーダーが付いていないため位置制御はできなくなりますが、音程で制御するので問題ありません。
考えてみると人間が歌うときのシャクリやビブラートなどの挙動はPID制御のオーバーシュートや振動によく似ています。人間が自分の歌声を聞いて声帯の締め具合を調節するのと同様に、PID制御で弦をチューニングして曲を演奏できるのではないかと考えました。ギターの弦とピックアップを使えば振動を電気信号にできるはずで、マイコンでその音程を解析してモーターの回転を制御すれば狙った音程を作れるはずです。
音程を制御するUIは鍵盤にするのが分かりやすいでしょう。片手で鍵盤を弾きながら、もう片方の手で弦を弾く形で演奏します。世界最古の電子楽器であるテルミンも、音量と音程を分けて演奏します。最近の話題でいえば鷲山技研さんのKey-bowedも、音程と音量を独立したUIとしています。それぞれで面白いインターフェイスを考えていくといろいろな楽器ができそうです。
目標設定
まず、弦の片端を固定し、もう片端をDCモーターに接続して、張力を変えられる出力装置を作ります。ピックアップで弦振動を電気信号に変換し、その周波数と鍵盤型のインターフェイスから入力された音程とを比較し、PID制御で弦の張力を変えるシステムを作ります。そして、配線などを極力隠して外見を整えます。
初期実験
大抵の問題は異なる規格の接続部で起きるので、そこからテストします。今回の場合、DCモーター→弦、弦→マイコンが怪しいので、それぞれがうまく接続できることを確認します。
まずは弦とモーターの固定テストです。モーターはタミヤのウォームギヤーボックスH.E.をチョイスしました。高トルク、逆流防止、静音駆動といった要件に対してはウォームギアが最適です。のちのちギア比を変えたくなることを見越して、4速ウォームギヤボックスH.E.もネットで注文しておきます。ギターの弦はとりあえず御茶の水でD'Addarioのちゃんとしたものを買いましたが、高かったのでAmazonで安めの弦を多数注文しておきます。急ぎの買い物は実店舗で買えるのが東京の良いところです。ギターの弦を買うのは初めてで何も分かりませんでしたが、とにかくスチール弦でいい感じのものを買いました(スチール弦でないとピックアップが機能しません)。
そのへんに落ちてた垂木にヒートンをぶっ刺して、ギターの弦の固定側にあるボールエンドを引っかけます。ボールエンドは弦と強固に接続されているため、これに乗っかります(こういう取っ掛かりない場合ワイヤー状のものは途端に扱いづらくなります。専用の圧着工具などが必要になります)
90cmほど離れたところにギアボックスをネジ止めしました。シャフトの穴に弦を通し、折り曲げたうえで乾電池を繋いで巻き付けると、十分強固に固定できました(ギターのヘッド側もこうやって固定しているようです)。また、巻き上げによって音程が振動することも無事確認できましたが、細い弦は切れてしまうこともわかりました。5弦か6弦を使って低めの音を出すのが安牌っぽいです。また、引っ張りが強すぎてヒートンが木を切り裂いて抜けかかっているので、もう少し見栄えの良い固定方法を考えておく必要がありそうです。
次は弦振動をマイコンに送るテストです。ギター用のピックアップは6連の大きいものしかないので自作します。仕組みとしてはコイルに磁石をくっつけるだけらしいです。別プロジェクトで余らせていた小型ソレノイドの両端にダイソーのネオジム磁石をくっつけ、適当にArduinoをオシロスコープ代わりにして確認すると、5〜10mV程度の(AnalogReadの値が1~2変わる程度の)微弱な電気信号が拾えました。いちおうピックアップとして機能しているようですが、増幅してやる必要がありそうです。
回路設計
ブレッドボード上に回路を組みます。
メインで使うマイコンはArduino Nano everyを選びました。ピンソケットが付いているArduino Unoなどと違い、Nano型はピンヘッダがついているので、そのままブレッドボードに刺して使えるのがうれしいです。Unoを使う場合、慎重に持ち運ばないとブレッドボードとArduinoを繋ぐジャンパー線が抜けてしまうことがあります。直接ブレッドボードに刺せればそういった心配はありません。Arduinoの電源は実験中は当面USB経由で良いでしょう。
ピックアップの電気信号の増幅にはオペアンプNJU7002Dを使いました。単電源で動作させるために、2回路入りのオペアンプの片方をボルテージフォロワとして使って1.65Vの安定した基準電圧を用意し、それを中間電位として0~3.3Vで増幅させました。はっきりと波形を見て取ることができます。
モータードライバーはTB67H450FNGを使いました。表面実装部品なので変換基板を介してピンヘッダにはんだ付けし、ブレッドボードで扱えるようにしました。モーターが食うような大電流はArduinoのピンを経由して送ってはいけないので、5Vピンなどは使わず電源から直接供給します。
モータードライバーに内蔵されているHブリッジ回路は入力信号によって正転、逆転、空転、回生ブレーキの4動作が得られます。入力の片方をLOW、片方をHIGHにすることで正転と逆転、両方LOWにすることで空転、両方HIGHにすることで回生ブレーキとなります。回生ブレーキを使用した方が急停止できて制御性が良いので、基本はどちらもHIGHとしておき、回転させたい量に応じてPWMでLOWの割合を増やしていく実装にしました。
#define MOTOR_PIN_A 2
#define MOTOR_PIN_B 3
// -1~1
void motorWrite(float val) {
val = constrain(val, -1, 1);
if (val == 0) {
analogWrite(MOTOR_PIN_A, 255);
analogWrite(MOTOR_PIN_B, 255);
}
else if (val < 0) {
val *= -255;
analogWrite(MOTOR_PIN_A, 255 - val);
analogWrite(MOTOR_PIN_B, 255);
}
else if (0 < val) {
val *= 255;
analogWrite(MOTOR_PIN_A, 255);
analogWrite(MOTOR_PIN_B, 255 - val);
}
}
ついでにオーディオアンプICLM386Gを積んで外部のスピーカーに出力できるようにしました。0.5Wの小型パッシブスピーカー(ネットには定格入力0.2Wとありますが本体には8Ω 0.5Wと印字されています……)も試したのですが、音量、音質ともに渋かったので、適当なアクティブスピーカーを接続することにします。ちなみに、アクティブスピーカーでも入力インピーダンスから計算すると1mAくらいの電流が流れるのに対し、オペアンプからは10μAしか取り出せないので、オーディオアンプなしでは足りないです。
それぞれのデータシートを見ながら適当な抵抗やコンデンサーなどをくっつけて、以下のような回路構成になりました。
制御
フーリエ変換の精度と時間のトレードオフ
ひとまずArduino Nano everyで拾った電気信号を解析して周波数を測定してみます。適当にArduinoFFTライブラリを使ったところ、難なくヒストグラ厶を表示することができました。高周波成分は要らないので4000Hzとしています。
#define FFT_SPEED_OVER_PRECISION
#include "arduinoFFT.h"
ArduinoFFT<float> FFT = ArduinoFFT<float>();
#define analogPin A7
#define SAMPLING_NUM 2048
#define SAMPLING_FREQ 4000
const unsigned long SAMPLING_PERIOD = round(1000000 / SAMPLING_FREQ);
float real[SAMPLING_NUM];
float imag[SAMPLING_NUM];
void setup() {
Serial.begin(250000);
while (!Serial);
}
void loop() {
// サンプルを収集
for (int i = 0; i < SAMPLING_NUM; i++) {
unsigned long us = micros();
real[i] = analogRead(analogPin);
imag[i] = 0;
while (micros() - us < SAMPLING_PERIOD); // 次のサンプリングまで待機
}
// FFTを計算
FFT.dcRemoval(real, SAMPLING_NUM); //直流成分除去
FFT.windowing(real, SAMPLING_NUM, FFT_WIN_TYP_TRIANGLE, FFT_FORWARD); // ウィンドウイング
FFT.compute(real, imag, SAMPLING_NUM, FFT_FORWARD); // フーリエ変換
FFT.complexToMagnitude(real, imag, SAMPLING_NUM); // 位相を無視して振幅を取り出す
// プロット
for (int i = 0; i < (SAMPLING_NUM / 2); i++) {
float freq = round(i * SAMPLING_FREQ / SAMPLING_NUM);
float ampl = real[i];
Serial.print("ampl:");
Serial.print(ampl);
Serial.println();
}
}
次に問題になるのはこれで必要な処理速度と精度がでているかです。周波数は±6%の相対誤差で半音ずれてしまうため、最低でも3%、理想は1%以下の精度が欲しいです。出す音は100〜200Hz程度になりそうなので、だいたい±1Hzとなります。また、PID制御することを考えると、せめて20msくらいで処理が終わって欲しいです。
離散フーリエ変換の結果はサンプリング周波数をサンプル数で等分割した分解能となります。例えば上記の定数では4000Hzのサンプリング周波数で2048サンプル集めているので、約2Hz刻みで各帯域の振幅が得られます。一方、サンプリングに必要な秒数はサンプル数/サンプリング周波数で求まります。例えば上記の定数では4000Hzのサンプリング周波数で2048サンプル集めているので、サンプリングには0.5秒かかります。このように精度とサンプリング時間は反比例の関係にあります。このトレードオフは不確定性原理と呼ばれ、量子力学とも密接な関係があります。
上記のプログラムでは、まずサンプルをたくさん取って、次にそれをフーリエ変換する、という流れになっていますが、これだとサンプルを集めるのに0.5秒かかるので、1秒に2回しかPID制御が回らないことになり、ちょっと遅すぎます。
対策1:割り込みとリングバッファ
サンプルを集める部分でサンプリング周波数を調整のために待機しているコードがあり、ここでCPU時間が無駄になっています。
// サンプルを収集
for (int i = 0; i < SAMPLING_NUM; i++) {
unsigned long us = micros();
real[i] = analogRead(analogPin);
imag[i] = 0;
while (micros() - us < SAMPLING_PERIOD); // 次のサンプリングまで待機
}
サンプルを収集している部分をマルチスレッド化すれば、このCPU時間でフーリエ変換を処理できそうです。サンプリング処理を各サンプルのタイミング(毎秒SAMPLING_FREQ回)で呼び出される割り込み関数に掃き出します(参考)。割り込み関数ではピン電圧を読み取って、毎回保存場所をずらしながらバッファに蓄積します(割り込み関数内でAnalogReadのような重めの処理をするのはよくないらしいですが、致し方ありません)。このようにして使うバッファはリングバッファと呼ばれる古典的な実装です。メインスレッドではリングバッファから直近順に測定値を読みだしてフーリエ変換を処理し、終わったらすぐ次の処理に進みます。フーリエ変換計算中もリングバッファは書き換わっていくので、計算用の配列はリングバッファと別に用意する必要があります。
#define FFT_SPEED_OVER_PRECISION
#include "arduinoFFT.h"
ArduinoFFT<float> FFT = ArduinoFFT<float>();
#define analogPin A7
#define SAMPLING_NUM 2048
#define SAMPLING_FREQ 4000
// FFT計算領域
float real[SAMPLING_NUM];
float imag[SAMPLING_NUM];
// リングバッファ
volatile unsigned int ringBufferPtr = 0;
volatile float ringBuffer[SAMPLING_NUM];
const unsigned long SAMPLING_PERIOD = round(1000000 / SAMPLING_FREQ);
void setup() {
pinMode(2, OUTPUT);
TCB2.CCMP = SAMPLING_PERIOD / 4; // TOP値の設定 4us単位
TCB2.CTRLB = (TCB2_CTRLB & 0b10101000) + 0b00000000; //タイマーのGPIO出力ON、クロックソースを設定
TCB2.CTRLA = (TCB2_CTRLA & 0b11111000) + 0b00000101; //カウント周期を設定してカウントスタート
TCB2.INTCTRL = 1; //割り込み許可
}
void loop() {
// リングバッファを直近順に整列しながら計算領域に移す
int head = ringBufferPtr; // 処理中に割り込まれても狂わないように別の変数に移す
for (uint16_t i = 0; i < SAMPLING_NUM; i++) {
real[i] = ringBuffer[(head + i) & (SAMPLING_NUM - 1)];
imag[i] = 0;
}
// FFTを計算
FFT.dcRemoval(real, SAMPLING_NUM); //直流成分除去
FFT.windowing(real, SAMPLING_NUM, FFT_WIN_TYP_TRIANGLE, FFT_FORWARD); // ウィンドウイング
FFT.compute(real, imag, SAMPLING_NUM, FFT_FORWARD); // フーリエ変換
FFT.complexToMagnitude(real, imag, SAMPLING_NUM); // 位相を無視して振幅を取り出す
// プロット
for (int i = 0; i < (SAMPLING_NUM / 2); i++) {
float freq = round(i * SAMPLING_FREQ / SAMPLING_NUM);
float ampl = real[i];
Serial.print("ampl:");
Serial.print(ampl);
Serial.println();
}
}
// 割り込み関数 サンプリングループ
ISR(TCB2_INT_vect) {
// リングバッファに記録
ringBuffer[ringBufferPtr] = analogRead(analogPin);
ringBufferPointer = (ringBufferPtr + 1) & (SAMPLING_NUM - 1); // (ringBufferPtr + 1) % SAMPLING_NUM をビット演算
}
直近1秒分のデータから測定するので即応性に欠ける(サンプリング時間の半分くらいラグがある)のは変わりないですが、制御ループが1秒数回という事態は避けることができ、おおよそリアルタイムに周波数を拾うことができました。
対策2:パラボラフィッティング
サンプリング時間を長くすると周波数が変化してからラグが出てしまいます。PID制御においてこのラグは無駄時間と呼ばれ、長いほど制御が難しくなります。そのため、できるだけサンプリング時間は短くしたいです。しかし、短くすると周波数分解能が落ちます。
ピーク値の前後3つの値を使って、その3点を通る放物線の頂点位置を求めると、1要素以下の幅でピークがどこにあるのかを推定することができます。あまりにサンプリング時間が短いと周波数のピーク自体が広がってしまうので、結局ある程度のサンプリング時間を取るのに越したことはないですが、推定しないよりはマシです。
実はArduinoFFTにそれに相当する関数(majorPeakParabola
)があったので盛大に車輪の再発明をしているのですが、高校数学の復習になったので実装します。
放物線
を満たすため、整理して
また放物線の頂点座標は
となります。これを実装すると以下のようになります。
float findPeak() {
// 最大値を探索
peakAmpl = 0;
peakFreq = 150; // この値が使われる可能性がごくわずかにあるのでinfやNaNではなくまともな数値にしておく
for (int i = 0; i < SAMPLING_NUM / 2; i++) {
// 局所最大でなければスキップ
if (real[i] < real[i - 1] || real[i] < real[i + 1]) continue;
// パラボラフィッティング
float p = 0.5 * (real[i - 1] + real[i + 1]) - real[i];
float q = 0.5 * (real[i - 1] - real[i + 1]);
float freq = (i + 0.5 * q / p) * SAMPLING_FREQ / SAMPLING_NUM;
float ampl = real[i] - 0.25 * q * q / p;
if (peakAmpl < ampl) {
peakAmpl = ampl;
peakFreq = freq;
}
}
}
メモリと処理時間の問題
マルチスレッド化によってサンプリング時間は無視できるようになりましたが、まだFFTの処理時間だけでも100〜200msかかっていました。処理時間を削るためにはサンプル数を減らす、ウィンドウ(サンプル範囲の始まりと終わりを滑らかにする処理)や三角関数を事前計算をする、高速な近似に置き換える、などが考えられます。
しかし、メモリもカツカツで、リングバッファと実部、虚部だけでサンプル数×12byte(4byte浮動小数を使用)のメモリを使うので、Nano everyの2kBしかないメモリでは512サンプルしか取れません。事前計算テーブルを入れると
ライブラリをやめて最小限のFFTを実装し直したり、平方根の近似を試したりしましたが大して変わらず…サンプル数を落とすしかないか……となっていました。
対策:プロセッサを変える
簡単な話で、Arduino Nano everyをやめて手元にあったArduino Nano 33 BLEで試したら爆速になりました。メモリも256kBと潤沢で計算速度も桁違いです(100ms→2ms)。動作電圧が5Vから3.3Vに変わりますが、オペアンプもモータードライバーも動作範囲内でした(モータードライバーのvccは別電源から)。しかしNano 33 BLEは純正ボードなので高いうえ、9軸IMUセンサーがついていてオーバースペック感があります。Nano Everyの倍の値段です。プロセッサとしてnRF52480が欲しいだけなら秋月謹製ボードのほうがお財布にやさしいのですが、店頭在庫がなく届くのに数日かかったうえ、ピン配置もNano 33 BLEと互換するわけでもなさそうだったので、諦めてNano 33 BLEをもう1台買いました。
nRF52480では割り込み関数内でAnalogReadが使えないとかの不都合がありましたが、Arduinoフォーラムを漁ってたら先人が書いたすごいコードが見つかったので利用させてもらいました。AnalogReadの裏にあるアナログデジタルコンバーター(ADC)を定期的に叩き、しかもそのたびに割り込み関数で呼び出してくれるというコードです。割り込み関数があるのでリングバッファも問題なく実装できます。
ノイズに対して敏感すぎる
簡単なP制御を書いてモーターとつなげてみた結果、ノイズ等に対して敏感すぎて荒ぶってしまいました。原因を一つずつ確認していきます。まずNano 33 BLEデフォルトのPWM出力は512Hzで、これがモーターに流れると磁気ノイズとなってスチール弦を通ってピックアップに入ってきます。次に商用電源由来のノイズで、50Hzの交流をACアダプタ内のブリッジダイオードで整流すると100Hzのリプルノイズとなって入ってきます。最後に倍音で、弦の響きの終わり際では基底周波数より倍音のほうが大きくなることがあって厳しいです。この他にも謎の70Hzあたりのノイズを拾うこともありました。
よくあるセンサーのノイズは真値の周囲をふらつくことが多いですが、今回はヒストグラムの最大値をもとにPID制御しているため、最大値が別のピークに飛ぶような挙動を示します。これで制御をすると大幅にモーター出力が変わって荒ぶってしまいます。
対策1:ノイズ源をなくす、カットする
電源ノイズは乾電池にすることでなくせます。モーターノイズは直接PWMをカスタマイズすることで高周波に上げました。そして、ピークの探索範囲を制御で使いうる周波数帯域に限定することで、低周波ノイズや高周波ノイズを無視するようにしました。弦を短くすることでも、同じトルクのまま音域を上げることができ、低音ノイズと弦の振動を区別できそうなので、90cmから40cmに減らしました。
対策2:オブザーバーを書く
測定値そのものを現在の周波数として利用するのではなく、過去の測定値から現在の周波数を推定する状態推定器ことオブザーバーを作ります。これについては長くなるので次の項で記します。
対策3:ギア比を上げる
ノイズとは関係ないですが、入力に対して回転が速すぎて荒ぶっている感じがしたので、タミヤの4速ウォームギアボックスの最低速に変えます。無駄時間が長くその後の立ち上がりが急峻な系の場合PID制御は困難になるそうです。今回はおそらくフーリエ変換のサンプリング時間の半分が無駄時間になるため、立ち上がりを遅くするのは有効だと思われます。
…………このギアボックスの加工中、利き手の人差し指を切りました。指の上で小さいパーツのゲート処理(下図)をしていたら勢い余ってザックリ……。血が止まらないので病院に行ったら2針縫うことになりました。これが今後電子工作など細かい工作をしていく上で面倒な枷になります。
オブザーバー
測定値の変動に惑わされないようにするため、オブザーバーを実装します。
カルマンフィルタ
まずは過去の推定値と現在の測定値を内分して急激な変化を抑えます。単純なカルマンフィルタは定数比で内分しますが、今回は信号強度によって信頼度が変わるので、大きい信号のときは測定値を、小さい信号のときは推定値を信頼するようにします。また、カルマンフィルタの追従速度がループ回数に依存しないよう、時間でべき乗してから適用します。
const float MIN_VOLUME = 100; // 音量がこの値以下のとき実測値を無視する
const float MAX_VOLUME = 600; // 音量がこの値以上のとき実測値を最大限に信頼する
const float MIN_KALMAN_RATE = 0.02; // 入力を最大限信頼するときの内分比(1秒前の値の影響量)
float estimatedFreq = 100; // 推定周波数(Hz)
// observedFreq: センサーから実測した周波数
// volume: 音量
// dt: 経過時間
void observer(float observedFreq, float volume, float dt) {
// 内分比を計算 音量が小さいときはsが1になり、過去の値+出力量をそのまま信じるようになる。
float s_volume = constrain(fmap(volume, MIN_VOLUME, MAX_VOLUME, 1, MIN_KALMAN_RATE), MIN_KALMAN_RATE, 1);
// ループ回数の影響を打ち消し
float s = pow(s_volume, dt);
// 観測した周波数で周波数を修正
estimatedFreq = s * estimatedFreq + (1 - s) * observedFreq;
}
誤差に基づく追従ブースト
また、今回の構成だとモーターを回さない限り、周波数が急変することはありません。これを利用して、信号強度が中くらいのとき、推定値に近い測定値の時は信頼度をブーストして1に近づけ、推定値からかけ離れているときはブーストせず小さい信頼度のままにするようにしました(かけ離れているときも信頼度を0にしないのは、推定値が実際の基底周波数と食い違ったときに修正が効かなくなるのを防ぐためです)。これにより、推定値と大幅に異なる値に急激に引っ張られるのを防ぎながら、推定値に近い時には追従速度を向上させることができます。
const float MAX_DIFF_FREQ = 20; // 周波数がこれより近い時、追従性をブースト
void observer(float observedFreq, float volume, float dt) {
// 内分比を計算 音量が小さいときはsが1になり、過去の値+出力量をそのまま信じるようになる。
float s_volume = constrain(fmap(volume, MIN_VOLUME, MAX_VOLUME, 1, MIN_KALMAN_RATE), MIN_KALMAN_RATE, 1);
// 推定との誤差に基づく係数
float s_error = constrain(fmap(abs(observedFreq - estimatedFreq), 0, MAX_DIFF_FREQ, 0, 1), 0, 1);
// 2つを合わせた修正係数
float s = pow(s_volume, 1 / s_error);
// ループ回数の影響を打ち消し
s = pow(s, dt);
// 観測した周波数で周波数を修正
estimatedFreq = s * estimatedFreq + (1 - s) * observedFreq;
}
物理モデリング
前段の「追従ブースト」は、モーターが動いていないことを前提にしているので、ブーストする幅を狭くすると、モーターを回したときに追従ブーストが弱くなったり切れたりしてしまいます。そこで、前の制御ループで設定したモーターの回転速度に応じて周波数の変化量を予測します。これによりモーターが動いているときでも追従ブーストをかけることができます。
まず、推定した周波数から張力を逆算します。弦の長さ
// 物理モデル
// 弦の長さ(m)
const float L = 0.4;
// 弦のゲージ番号(mil)
const float thickness = 30;
// 弦の線密度(kg/m)
const float rho = 0.000003 * thickness * thickness;
float freqToTension(float freq) {
return (freq * 2 * L) * (freq * 2 * L) * rho;
}
float tensionToFreq(float tension) {
return sqrt(tension / rho) / (2 * L);
}
また、弦が理想的な弾性変形をすると仮定すると、弦の張力
と表せます。巻き取り軸の角速度
で表され、モーターの回転数
で表されます。モーターの負荷トルク
です。以上を整理すると、
この式に従って張力を更新し、それを再び周波数に変換すればよさそうです。様々な要素が絡んでいて(特に弦のバネ定数はどこにも書いてないのでそれっぽい値を探るしかないです)有効数字一桁程度の雑な推定になってしまいますが、やらないよりはマシだと思います。なお、適正電圧3Vのモーターに対して6Vの電源をPWM出力で下げて使っているので、2*output
になります。
// 巻き取りシャフト半径(m)
const float r = 0.002;
// 弦のバネ定数(N/m)
const float k = 15000;
// ギア比の逆数(無次元)
const float a = 0.0007;
// モーターの角速度(rad/s) 無負荷回転数×π/30
const float N0 = 1050;
// モーターの最大負荷(Nm)
const float Tmax = 0.0005;
// observedFreq: センサーから実測した周波数
// volume: 音量
// output: モーターの出力量(-1~1 負で逆回転)
// dt: 経過時間
void observer(float observedFreq, float volume, float output, float dt) {
// 推定張力を計算
float S = freqToTension(estimatedFreq);
// 推定張力を更新
float torque = 0 < output ? min(2 * output, S * r * a / Tmax) : 0;
float omega = N0 * (2 * output - torque) * a;
S += k * r * omega * dt;
// Sの最小値を保証(誤計算でSが負になるとsqrtしてNaNになる)
S = max(10, S);
// 推定張力に基づいて推定周波数を再計算
estimatedFreq = tensionToFreq();
// ここから推定周波数を修正
}
その他、基底周波数を拾いやすくするために低音をブーストしてから大小比較するなども考えられますが、そのぶん低音ノイズ(乾電池にしても消えない謎のやつ)を拾いやすくなる可能性があったため、今回はやりませんでした。
PID制御
これらの対策をしたうえで、改めてPID制御を組んだところ、ある程度狙った音程を出すことができるようになりました。 PID制御については特筆することのない普通のコードで、パラメーターも手動で適当に探りました。しいて言えば周波数の差ではなく張力の差に基づいているくらいでしょうか。
音量が一定以下になった時は制御を切ることにしました。オブザーバー内で周波数を反映させない場合、周波数変動の予想だけでオープンループ的に動くことになりますが、そのままだと誤差が蓄積し弦がほどけてしまったりする危険があります。
弦交換などに伴う巻き上げ/緩め操作はいままで手で乾電池を直結していましたが、実際に組み付けてしまうとそうもいかないので、それぞれスイッチを設けます。二つを同時押しすることがない、どちらも押しっぱなしにすることはないという性質から、両側モーメンタリ(左右に倒すことができ、中央に自動的に戻る)形のスイッチにします。スイッチ操作後は元の位置に勝手に戻ろうとしないよう、いずれかのキーが入力されるまで制御を切ります。電源投入後も同様です。
const float Kp = 0.06;
const float Ki = 0.00001;
const float Kd = 0.02;
float P, I, D;
float P_;
const float maxOutput = 1.0;
const float CONTROL_THRESHOLD_VOLUME = 100; // 音量がこの値以下のとき制御を切る
float controlOutput;
bool enableControl = false;
void control(float targetFreq, float currentFreq, float volume, float dt) {
if (digitalRead(TIGHTEN_PIN) ==LOW) {
// 強制巻き上げ
enableControl = false;
controlOutput = maxOutput;
return;
}
if(digitalRead(LOOSEN_PIN) ==LOW) {
// 強制緩め
enableControl = false;
controlOutput = -maxOutput;
return;
}
if (volume < CONTROL_THRESHOLD_VOLUME) {
// 音量が一定以下の時は調律を無効
controlOutput = 0;
return;
}
if(!enableControl) {
// 強制巻き上げ/緩め操作後は(いずれかのキーを押すまで)調律を無効
controlOutput = 0;
return;
}
P = freqToTension(targetFreq) - freqToTension(currentFreq);
I += P * dt;
D = (P - P_) / dt;
P_ = P;
float amount = Kp * P + Ki * I + Kd * D;
controlOutput = constrain(amount, -maxOutput, maxOutput);
}
これで動作確認できました。環境が最悪で、畳の上を占領すると寝る場所がなくなるので片づけたほうが良いです。
鍵盤制作
電装系のめどが立ったので、ここからハードウェアを作っていきます。鍵盤は適当なボタンでも並べようかと思いましたが、やっぱり鍵盤らしさに欠けるので、気合で自作することにしました。キーボード用のキースイッチと3Dプリンター、レーザーカットしたアクリルを組み合わせて作る計画です。キーボード沼にはまだ入っていないので勝手がわかりませんでしたが、とりあえず秋葉原の遊舎工房さんに行ってCherry MX2Aの黒軸と、2Uスタビライザーを買ってきました。垂木を片手にスタビライザーを大量購入したら店員さんに訝しがられてしまいましました。
キーキャップ設計
キーキャップは3Dプリンターで作ります。ちょうど先月A1 miniが自宅に届いたので、仕事してもらいます。3Dプリンターを貸してくれる設備や代行してくれるサービスもありますが、家にプリンターがあるとポンポンと試作を出してモデルを修正といったループが回せるのがうれしいです。Fusionを使ったことがなかったので急いでインストールして一夜漬けで覚えました。
軸
キースイッチ→キーキャップ間の接続は、FDM軸なるものを作っている先駆者がいたので、それを参考に調整して設計します。長い方の切り込みは狭めにして押し広げながら通し、短い方の切り込みは確実に入るように設計されていて、本当にすごいです。何度か調整しながら出力したところ、狭いほうが1mm、広いほうが1.2mmとなりました。キーの高さに余裕があるので、押し広げる力がきれいに分散されるよう、キーの高さを利用して切り込みを深くし、太さが不連続なところがあるとそこから折れるのでフィレットを加えました。外側が干渉しそうだったのでキースイッチにはめる部分とスタビライザーにはめる部分で向きを変えることで避けています。
黒鍵
接続軸がハマることが確認できたので、その上にくっつける鍵盤型のキーキャップを設計します。黒鍵はすべて同形状なので簡単だろうと先に手を付けたのですが、黒鍵の角にある面取りをする機能がFusion になかったため、思ったより面倒でした。スケッチで「1点で交差する3直線に同時に接する球」を作図しようとしたのですが、作図手順を誤ったのか計算誤差が蓄積したのか全然精度が出ず、仕方なく手動で二分探索して位置を合わせました。
白鍵
白鍵は以下のような寸法で設計しました。白鍵は音階ごとに形が微妙に違ってややこしいです。特にドとファ、ミとシは細い部分の幅が1mm違うのみだったので、区別しやすいようドとレとミだけ裏側に突起を付けて管理しました。白鍵同士、黒鍵と白鍵の間は1mmのクリアランスを確保しています。
表面処理
キーキャップが造形出来たら表面処理を行います。ピアノの鍵盤と言えば光沢が特徴的ですが、光沢があると表面の傷や汚れが目立つので要求される表面精度が上がるため、難易度が上がります。今回そこまでやる余裕はないので艶消しで統一します。
造形時、指に触れる上面を下にしてスムーズPEIプレートで造形することで、天面が平らになるようにしておきました。黒鍵は側面の積層跡や斜面のオーバーハングのダレが目立つので、ペンサンダーに神ヤス!を両面テープで貼り付けてやすり、マットブラックのラッカーで塗装してツヤ消し感を揃えます。何種類かの塗料や重ね方を検討したのですが、結果的にダイソーのラッカーが一番素直な質感になりました。白鍵は黒鍵に比べて積層痕が目立たなかった上、ラッカーを吹くと透明度が下がって余計に凹凸が目立ったので、面取りした曲面部をやするのみの処理としました。
操作パネル
キーボードの横にあるパネルもついでに設計します。パネルにはオンオフスイッチ、巻き上げ/緩めスイッチ、ボリュームの3つをマウントします。すべてナットで固定できるものなので穴を開ければ終わり…だと思っていたのですが、ゆるみ止め加工をしなきゃいけないらしいです。また、アクリル板には3mmのタッピングビスで締結するので、それを受けるボスも造形する必要がありました(参考)。アクリル板を切り出す時点ではパネルの設計ができていなかったので、適当に開けておいた穴に合わせて強引に設計します。
造形できたら黒鍵と同じように表面を処理します。多少積層跡が残ってしまいましたが、時間がないのでGOサインとします……。UIの刻印を3Dプリントしたかったのですが、造形精度が出なかったのでここは印刷物を貼って誤魔化します。
文字がつぶれています
キープレート設計
キープレートはアクリル板をレーザーカットして作ります。アクリル専門店のはざいやさんにマットアクリル板があったので3mmの黒を注文しました(2mmのものも注文したのですが、納品がスケジュールに間に合いませんでした。限界スケジュールでやってるこっちが悪いので、別の何かに活用します…)。PCBを作っている時間はないので、PCBもアクリルと手配線で代用します(スタビライザーがトッププレートではなくPCBで支える構造だったので、トッププレートだけという構造にはできませんでした)。
Cherry MXシリーズの規格を確認したり現物をノギスで測ったりしながら、キースイッチをはめ込むための穴を設計します(データシートがインチで書かれていて換算がめちゃくちゃ面倒でした)。キースイッチの爪は1.6mmの金属板用に設計されているのですが、アクリルは3mmあるので、爪がかかる部分を薄く削る必要があります。レーザーで似た加工をしたことがあったのでそれで乗り切ります。
レーザーカッターの切断幅で0.2mmくらい広がるため、はめあいのキツさなどは実際に切ってみて調整する必要があります。まず試作として1ユニットが問題なくハマることを確認したのち、それを鍵盤型に配置したデータを作ってカットします。
(この時点の黒鍵は試作版で面取りしていなかったので、これだと手にあたる部分が痛いことがわかりました。)
組み立て、配線
パーツが揃ったのでアクリル板にはめ込んで組み立て、M3ネジで挟み込みます。トッププレートとPCBの隙間について、規格上はトッププレート上面からPCB上面までが0.197inch=5mmで、トッププレート代わりにしているアクリル板が3mmなので、トッププレートとPCB代わりの隙間は2mmであるはずなんですが、スタビライザーのワイヤーなどが干渉しそうだったため、M3ナット(2.4mm)が結果的にちょうどよかったです。
裏側から手配線します。15キーあるので3×5のマトリクスになるようアドリブで配線を考えます。実際にマトリクス状に並んでいるわけではないので配線がすごく変則的です。できるだけ交差が少ないようにレイアウトした上で、すずめっき線が重なるところはマステを重ねて絶縁しました(被覆線の在庫がほとんどなくて………)。
……PCBは熱硬化性樹脂であるエポキシで作られていますが、アクリルは熱可塑性樹脂なので当然ハンダの熱で溶けます。実質空中配線している感じです。空中配線のコツはがっつり仮止めしてパーツが動かないようにすることです。ハンダごてとハンダで両手が塞がるので、触らなくても部品が固定されている必要があります。マステとハンダ台を駆使して仮止めして作業します。
最後に、キーボードの端子をピンヘッダに繋ぎます。今回はすべてをブレッドボードとジャンパーワイヤで配線するのですが、中間配線を延長しやすくするために、機器はブレッドボードに向かう側をオス端子で統一し、中間配線はオス-メスのジャンパーワイヤーで繋ぐ作戦で行きます。オスメスのワイヤーはオスオスやメスメスのジャンパーと違い、オスを延長してもオスのままなので、端子のオスメスについて深く考えなくて良くなります。
配線でき次第Arduinoに繋いで動作確認したところ、問題なく通りました。キーマトリクスのダイオードの向きとかややこしかったので不安でしたが無事動いてよかったです。
チューニングする周波数は押下したのが一番最後のキーとするため、押した瞬間を記録するタイムスタンプを用意しました。
// スイッチの状態と押し始めた時刻
bool switchStates[15] = {false};
long switchTimestamps[15] = {0};
const int keycodeToNote[15] = {14, 11, 7, 4, 2, 12, 9, 6, 3, 0, 13, 10, 8, 5, 1};
int latestNote = 0;
int checkSwitches() {
unsigned long currentTime = millis();
// 出力ピンを順にLOWに設定し、入力ピンの状態を読み取る
for (int i = 0; i < 3; i++) {
// 現在の出力ピンだけLOWに設定
digitalWrite(KEYBOARD_OUT_PINS[i], LOW);
// 3msくらいくれてやる
delay(1);
// 入力ピンを読み取る
for (int j = 0; j < 5; j++) {
int keycode = i * 5 + j; // スイッチのインデックスを計算
// 押されていればtrue
bool currentState = digitalRead(KEYBOARD_IN_PINS[j]) == LOW;
// 押し始めた瞬間なら時刻を記録し、制御をオンに
if (currentState && !switchStates[keycode]) {
switchTimestamps[keycode] = currentTime;
enableControl = true;
}
switchStates[keycode] = currentState; // 状態を記録
}
// 現在の出力ピンをHIGHに戻す
digitalWrite(KEYBOARD_OUT_PINS[i], HIGH);
}
// 最後に押し始めた(タイムスタンプが最も近い)キーを取得
int latestKeycode = 0;
unsigned long shortestDuration = currentTime - switchTimestamps[0];
for (int i = 0; i < 15; i++) {
unsigned long duration = currentTime - switchTimestamps[i];
if (duration < shortestDuration) {
latestKeycode = i;
shortestDuration = duration;
}
}
latestNote = keycodeToNote[latestKeycode];
}
操作パネル内のボリュームツマミ等はピンヘッダをはんだ付けしておいてオス-メスのジャンパーワイヤーで接続する作戦だったのですが、仮組してみるとあまりに奥まっていて抜けたときのフォローが不可能そうだったので、オス-オスのジャンパーワイヤを直接はんだ付けする方針に転換します。また、電源のマイナス側をパネル内に持ってきてあったので、パネル内でグラウンド同士を配線し、外に引き出す線を減らします。中途の配線剥きが厄介でしたが、非耐熱の被覆だったのでハンダごてで焼き切ることができました。
なんやかんやあってキーキャップをはめると鍵盤ユニットは完成です。ずいぶんかわいい鍵盤になりました。
筐体制作
設計
筐体のメインの素材はホームセンターで買った18mm厚のラジアータパイン集成材です。910mm×1820mmで6000円くらいだったのですが、半分も使わなかったので実質2000円くらいだと思います。ベニヤ板やランバーコアと違い積層がないので、面取りをしても断面を美しく出せます。左右側面はアクリル板を接着して小口を隠します
イラレでサイズを数値入力した長方形を並べて三面図を書きます(Fusionはまだ慣れていないので、3Dプリントでしか使っていません)。全体の寸法は後から数ミリ変わることもありますが、鍵盤と木の段差などはミリ単位で決めておく必要があります。弦を鍵盤の奥に配置するとコンパクトにできますが、鍵盤とのバランスが悪かったので、弦を鍵盤の左側に配置し、横に長いシルエットにしました。
ピックアップの位置は弦長に対して黄金比の位置に来るようにしてみました。例えばピックアップが弦の中央にあると、基底振動や3倍振動は問題なく拾えますが、2倍振動や4倍振動は振動の節の位置になるため入らず、結果三角波に近い波形になると予想されます。黄金比は無理数の中でも最も整数比に近似しづらい性質があるため、すべての倍音をバランスよく拾った音色になるはずです。
設計ができたら、木工作業中に迷わないよう、必要なパーツと寸法を書き出しておきます。
木工
まずは材料をパネルソーで設計したサイズに切り出します。パネルソーは板状のものを水平垂直に切り出すことができる設備で、こういうときには重宝します。私は大学の設備でやりましたが、ないときはホームセンターで切ってもらうのがいいと思います(私の場合、材料購入時点で設計がなかったので……)。
次に、上面の角に対して、トリマーでボーズ面を取ります。角をひとつ丸めるだけでもだいぶ無骨さが減ると思われます。本当はR18mmくらいで丸めたかったのですが、大学の工房にあったのが最大でR12.7mmのビットだったので、それを使いました。トリマーというのは高速回転する歯で面取り処理や溝切加工などができる電動工具です。面取り処理はビットについているベアリングをガイドとして当てることで、側面はもとの形を残したまま角だけ削ることができます。
あとは鍵盤が収まる部分をジグソーでコの字型に切り欠いたり、自作ピックアップを入れるφ20mmの穴やギアボックスのシャフトが通るφ8.5mmの穴をドリルで開けたりします。
各パーツができたら上面と前面をボンドとクランプで圧着し、強度が欲しい部分は(貫通しないように気をつけて)裏から添え木を介してビスで締結します。キーボードの奥にある木材も、見える部分がある=塗装する必要があるので、この時点でくっつけてしまいます。
表面処理
表面の仕上げに240番紙ヤスリで研磨してニスを塗ります。キーボードと同様、今回は艶消しで統一します。ニスやステインなどの塗り重ね方を何通りか検討して、和信ペイントの水性ニス(けやき色)と水性ウレタンニス(つや消しクリア)を2層ずつ塗り重ねることにしました。各層の間と仕上げには400番の紙やすりで軽く研磨し、密着性を上げたりツヤ感を揃えたりします。ちなみにこの過程で、ツヤありのニスでも充分に薄めて塗るとツヤ消し仕上げになることを発見しました。
組み立て
組み立ての際にはトポロジカルソートで手順を整理するのが便利です。例えばAとBとCをくっつけたいとき、BとCを先に合体してしまうとAをくっつけるためのドライバーが入らなくなる……みたいな場面も起きます。こういった制約が複雑になってきたときはトポロジカルソートが役に立ちます。まずやらなければいけない作業(接合したいパーツ関係など)を列挙し、工程間の順序が強制されているところに有向辺を書き込みます。このときサイクルが出来ていると詰んでいて、固定方法を変えないといけなくなります。サイクルがなければソートが成功し、また自由度が残っていればどこの手順を入れ替えることができるかも把握することができます。
まず、巻き上げ軸の縁にブッシュを圧入します。これは本当のギター用のパーツで、千石電商で見つけました。ハンマーで叩き込むのは怖いのでクランプでゆっくり締め付けて押し込みます。
固定端側は八幡ねじ製のステンレスミニステーを組み合わせて使いました。ステンレス製なので剛性もばっちりです。ここだけDIY感が強く出てしまいましたが、ひん曲がったヒートンよりはマシでしょう。ちなみに逆向きで設置するとLステーが回転する可能性があるのでダメです。
ギアボックスと木材を固定しその塊を本体をボンドとビスで強固にくっつけます。この接着でギアボックスに問題が発生しても分解できなくなってしまいました。ここからお祈りタイムが始まります。
余らせていたMDFで底面を閉じます。ここではボンドは使わず、ビスの本数も最小限にします。あとで開ける可能性があるからです(実際開けました)
背面にメンテ用の開口部を設けます。φ6.5mmビットで貫通しない穴を開け、ダイソーのネオジム磁石を詰め込んで2液性エポキシ接着剤で強固に封じ、保持できるようにします。内側に指を突っ込んで押して外せるように、底面のMDFにφ20mmで穴を開けました。
キープレートと同じアクリル板をエポキシ接着剤で貼り付け、側面を閉じます。これは小口隠しのほか、スピーカーや電源(乾電池のつもりですが念の為)用の外部接続端子を固定するためにも必要なパーツです。木材の加工精度の問題で(前面と上面の寸法のズレなど)0.7mmほどの段差が残っているため、充填接着できる2液性エポキシ接着剤を使う必要があります。まずレーザーで切った断面にツヤが出ているので400番ヤスリで消しました。余計なところに接着剤が付くと落ちないので、付きそうな範囲の3倍はマスキングしておきます。良く練った接着剤をアクリル板と木の両面に塗り、張り合わせたらずれないようにテープで固定します。
3Dプリントで作っておいたホルダーにソレノイドを固定して差し込みます。これを差し込むための穴はφ20mmの木工ビットで開けましたが、実測すると19.9mmしかなかったので、19.6mmでプリントしておきました。板から抜けないようにする爪も造形してあります。ソレノイドを固定する側のほうにこそ爪が必要な気もしますが、位置(弦との距離)を調整する可能性があったので作りませんでした。見えない部分なのマステで強引に固定します……(なんだかんだ摩擦だけでも保持してくれるくらいのフィット感なので大丈夫そうです)。こういう既製品では絶対に手に入らない接続パーツを作れるのが3Dプリンターの一番の強みだと思います。
キーボードのプレートを木にビス止めしようとしていたのですが、木の加工精度の問題で下穴の位置が合わず、諦めました。キツキツにハマっているので大丈夫だと思います。次こういうことがあったら長孔加工にしておきます。
オス-メスジャンパー線を裏側で配線します。当初は底面にブレボを置いて背面から作業する予定でしたが、あまりに狭かったので、前面の裏にブレボを貼り付けて底面パネルを開けて作業しました。結果的に、ブレボと各パーツが剛性のある木を介して固定された状態になったので、配線が抜ける心配が少なく、作業性も良好でした。事前にほぼ動作確認できていたので、電気的に接続が変わらないよう再現するだけです。ボリュームツマミだけアドリブだったので想定外がありましたが、何とかなりました(アンプの前で分圧しようとしたのですが、10kΩの分圧回路に回すほどもオペアンプに電流供給能力がなかったので、アンプ後に分圧する形に変えました)。
最後に、UIパネルの印刷をしていなかったので、コンビニプリントに入稿してコンビニまで走って刷りに行きます。シール印刷を試したのですが、つやがあり過ぎて合わなかったので普通のコピー紙印刷をのりで貼りました。
完成・振り返り
最後にすべての動作確認をして完成です。
プロジェクトを通じて、PID制御で周波数を操作すると口に出すのは簡単ですが、実際はかなり面倒な実装が多くあることを学べました。また、Fusionを覚えて3Dプリンターを実戦投入できたのは良い経験だったと思います。
今回は手動でいい感じにパラメーターを探っている箇所が多かったので、次は実測値に基づくパラメーター決めや自動キャリブレーションなどを実践してみたいです。今後の課題とさせてもらいます。発注する時間がなかったPCB設計もやってみたいですね。
Discussion