lipoyang が 2025年01月17日11時52分48秒 に編集
コメント無し
本文の変更
# コンセプト 大正琴という楽器をご存じでしょうか? 大正琴は大正時代に考案された楽器で、左手で鍵盤を押さえ、右手のピックで弦を弾いて演奏するのが特徴です。現在ではアンプで音を増幅する電気大正琴や、シンセサイザーでさまざまな音色を出せる電子大正琴も存在します。 ![大正琴](https://camo.elchika.com/9c415448bb1a35b2b66dee092f042749965ded6c/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f36303539333464372d386264382d343539372d383033642d303135663561636436323261/) 「大正こンパクと」は省スペースを追求した新しい電子大正琴です。一般的な大正琴には26個前後のキーがありますが、「大正こンパクと」はたった8個のキーと4本の弦の組み合わせで2オクターブ以上の音域をカバーします。このため横幅25cmと、一般的な大正琴の3分の1ほどのサイズになります。
# 動画
@[youtube](https://www.youtube.com/watch?v=B-H45Hiuvkg) # 仕様 「大正こンパクと」は ①~⑧の8個のキーと、G線・D線・A線・E線の4本の弦を持ちます。各弦の音域は、G線がG3~D4、D線がD4~A4、A線がA4~E5、E線がE5~B5です。詳しくは下表に示します。 ![キャプションを入力できます](https://camo.elchika.com/f359d23645113bce001b23ff9b2e4e54902b48ea/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f66653235376433392d313231652d346262632d616535352d626430326366393436613762/) 4本の弦の振動をエレキギター用のピックアップで検出しますが、どの弦が弾かれたかを周波数で判別できればよいので正確な調律は不要です。スピーカに出力する音は、上記の表にしたがった音程をデジタルで生成します。音の生成にはサンプリング音源データを用い、琴の音色を再現します。 ![キャプションを入力できます](https://camo.elchika.com/aa65b05e774370d49a2d5bdaa239ff09b754b012/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f63623938623038632d373832652d346438352d386161342d343634643266636631636634/) 以上の仕様を実装するため、マイコンボードは SPRESENSE を使用しました。 SPRESENSEの拡張ボードは スピーカ出力端子やマイク端子、マイクロSDカードスロットを備えており、またオーディオ系や楽器作りのための豊富なライブラリとサンプルコードも用意されているためです。ピックアップはSPRESENSEのマイク端子に接続します。 # ハードウェア ## 部品表 |名称|型番・備考| |---|---| |SPRESENSEメインボード / 拡張ボード| CXD5602PWBMAIN1 / CXD5602PWBEXT1 | |ピックアップ (4弦エレキギター用) | Amazonで見つけたもの。1弦-4弦間 30mm | | メカニカルキースイッチ | Cherry MXスイッチ (赤軸) × 8個| | キーキャップ| DSAキーキャップ 赤×4個 (黒鍵用), 黄×4個 (白鍵用)| |スピーカ|8Ω 2W / SS-30-802W | |D級アンプモジュール | PAM8403使用 / MOD602-PAM8403-15SP | |スイッチ付きミニジャック|ステレオミニジャック スイッチ付き / MJ-352W-C| |音量調整用ボリューム|基板用2連可変抵抗 Aカーブ 10kΩ φ6mm軸| |DC/DCコンバータ| DC4.5~9V → DC5V, 1A / OAS6R0-0505 | |主電源スイッチ|超小型波動スイッチ / KCDI-11 2P| |埋め込み電池ボックス| 単三電池4セル用 / タカチ LD-4B| ## 結線図 ![キャプションを入力できます](https://camo.elchika.com/562f3e63465e5370ce7063b7c69b4f10813ae874/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f61613139636365612d363235652d343037332d386561612d386263383733306439313665/) ## 製作 上記の結線図の通りに電装系を製作しました。キーボード部の基板はユニバーサル基板で作りました。そのためキーピッチは、2.54mm ×8 = 20.32mm となり、一般的なキーピッチである 3/4インチ = 19.05mm よりやや広くなります。キースイッチの足はユニバーサル基板の穴位置にピッタリは合わないので少し折り曲げてハンダ付けしています。電装系はひとまず下の写真のように適当な板に固定した状態で動作確認してから筐体に組み付けます。 ![キャプションを入力できます](https://camo.elchika.com/62d59a84f5def587e8ec13e8f4f765825753e46f/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f30643761383934632d613233392d343834362d616564362d306536653838363630636332/) ## 一口メモ - 夜中でもヘッドホンを使って演奏できるようにスイッチ付きミニジャックを設けました。ヘッドホンを挿すとスピーカからは音が出なくなります。 - SPRESENSE 拡張ボードに搭載のヘッドフォンアンプ NJU72040 は 80mW @ 32Ω と、スピーカを直接駆動するには弱いので、D級アンプを設けました。 - 弦の間隔が10mm程度の4弦用のピックアップで安価なものがなかなか見つからず、たまたまAmazonで見つかったものを使用しました。 # ソフトウェア ## 開発環境 - IDE : PlatformIO (環境導入については → [PlatformIO で Spresense](https://lipoyang.hatenablog.com/entry/2024/11/12/201737) ) - フレームワーク : Arduino - 依存ライブラリ : ssprocLib ## 構成 SPRESENSEで簡単に楽器を開発するライブラリとして、[Sound Signal Processing Library for Spresense](https://github.com/SonySemiconductorSolutions/ssih-music/) (ssprocLib) が提供されています。ssprocLib を使った楽器は、鍵盤やマイクなどの入力を受けて演奏データを発行する```Src```と、演奏データを受けて音を出す```Sink```という2つのクラスで構成されます。 「大正こンパクと」のソフトウェアはssprocLibを利用し、下図のような構成になっています。 ![キャプションを入力できます](https://camo.elchika.com/66b77567953cdaea9870fa81aee889b976622cbb/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f37333036353132622d363731332d343265382d396334312d613962396334623664376230/) ## 弦の振動の検出 ssprocLib の```VoiceCapture```クラスは```Src```クラスの一種で、マイク端子から入力された音声信号の周波数と音量を解析します。これを利用してピックアップで拾った弦の振動の周波数を解析し、どの弦が弾かれたかを判定します。 ```VoiceCapture```クラスはメインコアとサブコア1が協調動作するようになっており、メインコアでマイク入力をバッファし、サブコア1でFFT(周波数解析)を演算します。サブコア1側のプログラムは、ssprocLib のサンプルコードの```YuruHorn_SubCore1.ino```をベースに修正したものを使用します。```YuruHorn_SubCore1.ino```は人の声を入力するためのものなので、周波数のパラメータなどを変更しました。 メインコア側のプログラムは、```VoiceCapture```クラスを継承した```Kompacto```クラスを作成しました。 ```Kompacto```クラスは、サブコアで検出したピーク周波数からどの弦が弾かれたを判定するとともに鍵盤のキー入力も受けて出力すべき音程を決定し、ノートオン/ノートオフを発行します。 ```cpp:Kompacto.h #pragma once #include <VoiceCapture.h> // 「大正こンパクと」クラス // VoiceCaptureクラスを継承 (マイク入力からピーク周波数と音量を取得できる) class Kompacto : public VoiceCapture { public: // コンストラクタで鍵盤のピン番号、各弦の周波数、後段を指定 Kompacto(const int* pin_keyboard, const float* f_strings, Filter& filter) : VoiceCapture(filter), PIN_KEYBOARD(pin_keyboard), FREQ_STRINGS(f_strings) {}; // 開始処理をオーバーライド bool begin() override; protected: // サブコアからピーク周波数検出を受けるコールバックをオーバーライド // freq_number : 周波数の分子 // freq_denom : 周波数の分母 (freq_number / freq_denom が Hz単位になる) // volume : 音量 void onCapture(unsigned int freq_numer, unsigned int freq_denom, unsigned int volume) override; private: const int* PIN_KEYBOARD; // キーボードのピン番号のテーブル const float* FREQ_STRINGS; // 弦の周波数のテーブル unsigned int _prev_volume; // 前回の音量 int _detect_cnt; // 有効な検出の連続カウンタ int _last_note; // 各弦の最後の音符 }; ``` ```cpp:Kompacto.cpp // 「大正こンパクと」クラス #include "Kompacto.h" #include "note.h" // 撥弦判定の閾値 static const int VOLUME_THRESH = 800; // 音量閾値 static const int LENGTH_THRESH = 1; // 時間閾値 // 撥弦判定の周波数の許容誤差[Hz] static const float F_ERR = 75.0F; // 音程のテーブル static const int NOTE_TABLE[4][9]={ // 開放 キー1, キー2 キー3 キー4 キー5 キー6 キー7 キー8 {NOTE_G3, NOTE_GS3, NOTE_A3, NOTE_AS3, NOTE_B3, REST, NOTE_C4, NOTE_CS4, NOTE_D4}, // G線 {NOTE_D4, NOTE_DS4, NOTE_E4, REST, NOTE_F4, NOTE_FS4, NOTE_G4, NOTE_GS4, NOTE_A4}, // D線 {NOTE_A4, NOTE_AS4, NOTE_B4, REST, NOTE_C5, NOTE_CS5, NOTE_D5, NOTE_DS5, NOTE_E5}, // A線 {NOTE_E5, REST, NOTE_F5, NOTE_FS5, NOTE_G5, NOTE_GS5, NOTE_A5, NOTE_AS5, NOTE_B5}, // E線 }; // 開始する bool Kompacto::begin() { // 変数の初期化 _last_note = INVALID_NOTE_NUMBER; // 鍵盤入力の初期化 for(int i = 0; i < 8; i++){ pinMode(PIN_KEYBOARD[i], INPUT_PULLUP); } // 親クラスのbegin() return VoiceCapture::begin(); } // サブコアからピーク周波数検出を受けるコールバック // freq_number : 周波数の分子 // freq_denom : 周波数の分母 (freq_number / freq_denom が Hz単位になる) // volume : 音量 void Kompacto::onCapture(unsigned int freq_number, unsigned int freq_denom, unsigned int volume) { // 立ち上がり if (_prev_volume < VOLUME_THRESH && volume >= VOLUME_THRESH) { _detect_cnt = 0; } // 持続 else if (volume >= VOLUME_THRESH) { // 時間閾値に達したら有効な撥弦とみなす _detect_cnt++; if(_detect_cnt == LENGTH_THRESH){ // 周波数 float freq = (float)freq_number / (float)freq_denom; printf("Detect freq %f, volume %d\n", freq, volume); // 弦の判定 int pick = -1; for(int i = 0; i < 4; i++){ if( (freq > FREQ_STRINGS[i] - F_ERR) && (freq < FREQ_STRINGS[i] + F_ERR) ) { pick = i; break; } } if(pick >= 0){ // 音量 int velocity = volume / 20; if(velocity > 127) velocity = 127; // 鍵盤の判定 int key = 0; // 0:開放弦 for(int i = 0; i < 8; i++){ if(digitalRead(PIN_KEYBOARD[7 - i]) == LOW){ key = 8 - i; // 1~8 : キー1~8押下 break; } } // 出力 int note = NOTE_TABLE[pick][key]; if(_last_note != INVALID_NOTE_NUMBER){ VoiceCapture::sendNoteOff(_last_note, DEFAULT_VELOCITY, DEFAULT_CHANNEL); } VoiceCapture::sendNoteOn(note, DEFAULT_VELOCITY, DEFAULT_CHANNEL); _last_note = note; if(onNoteOn != nullptr) onNoteOn(note, freq); printf("string %d, note %d, velocity %d \n", pick, note, velocity); }else{ if(onNoteOn != nullptr) onNoteOn(0, freq); } } } // 立ち下がり if (_prev_volume >= VOLUME_THRESH && volume < VOLUME_THRESH) { //VoiceCapture::sendNoteOff(note, DEFAULT_VELOCITY, DEFAULT_CHANNEL); } _prev_volume = volume; } ``` ## 琴の音の生成 ssprocLib の```SFZSink```クラスは```Sink```クラスの一種で、SFZ形式のサウンドフォントを利用して簡単にサンプリング音源を実装できます。ただし、```SFZSink```で扱える SFZ には制約があります。そこで、```SFZSink```で扱えるSFZに変換するスクリプト [import-sfz.py](https://github.com/SonySemiconductorSolutions/ssih-music/tree/develop/tools) が提供されています。 琴の音色のSFZデータとして、[SFZ Instruments](https://sfzinstruments.github.io/)様から [13 Strings KOTO (Ui_KOTO)](https://sfzinstruments.github.io/folk/koto/) のデータをダウンロードして利用しました。Ui_KOTO には13弦琴の多様な奏法のSFZデータが収録されていますが、その中でもいちばんふつうの奏法のデータと思われる(?) ```Sustain_Front.sfz``` を上記の```import-sfz.py```で変換しました。 ところが、変換した SFZ を```SFZSink```に与えても音が再生できません。 SFZファイルを確認したところ、かなり複雑な指定がされていました。そこで、サンプルコード用の音源ファイル([assets.zip](https://github.com/SonySemiconductorSolutions/ssih-music/releases/latest/download/assets.zip)) の```SawLpf.sfz```を参考に手作業で簡単なSFZファイルを作成したところ、再生できました。ポイントとして、```SawLpf.sfz```は持続的なノコギリ波の音色なので```loop_mode=loop_continuous```ですが、琴は撥弦楽器であり持続しないので```loop_mode=no_loop```としました。それにともない、```loop_start```や```loop_end```もなくしました。サンプル音源データ(WAVファイル)は、```import-sfz.py```で変換されたものを使用しました。 ```xml:koto.sfz <group> loop_mode=no_loop <region> key=50 sample=Koto/050050_Front_d3_Sustain1.wav <region> key=51 sample=Koto/051051_Front_d#3_Sustain1.wav <region> key=52 sample=Koto/052052_Front_e3_Sustain1.wav <region> key=53 sample=Koto/053053_Front_f3_Sustain1.wav <region> key=54 sample=Koto/054054_Front_f#3_Sustain1.wav <region> key=55 sample=Koto/055055_Front_g3_Sustain1.wav <region> key=56 sample=Koto/056056_Front_g#3_Sustain1.wav <region> key=57 sample=Koto/057057_Front_a3_Sustain1.wav <region> key=58 sample=Koto/058058_Front_a#3_Sustain1.wav <region> key=59 sample=Koto/059059_Front_b3_Sustain1.wav <region> key=60 sample=Koto/060060_Front_c4_Sustain1.wav <region> key=61 sample=Koto/061061_Front_c#4_Sustain1.wav <region> key=62 sample=Koto/062062_Front_d4_Sustain1.wav <region> key=63 sample=Koto/063063_Front_d#4_Sustain1.wav <region> key=64 sample=Koto/064064_Front_e4_Sustain1.wav <region> key=65 sample=Koto/065065_Front_f4_Sustain1.wav <region> key=66 sample=Koto/066066_Front_f#4_Sustain1.wav <region> key=67 sample=Koto/067067_Front_g4_Sustain1.wav <region> key=68 sample=Koto/068068_Front_g#4_Sustain1.wav <region> key=69 sample=Koto/069069_Front_a4_Sustain1.wav <region> key=70 sample=Koto/070070_Front_a#4_Sustain1.wav <region> key=71 sample=Koto/071071_Front_b4_Sustain1.wav <region> key=72 sample=Koto/072072_Front_c5_Sustain1.wav <region> key=73 sample=Koto/073073_Front_c#5_Sustain1.wav <region> key=74 sample=Koto/074074_Front_d5_Sustain1.wav <region> key=75 sample=Koto/075075_Front_d#5_Sustain1.wav <region> key=76 sample=Koto/076076_Front_e5_Sustain1.wav <region> key=77 sample=Koto/077077_Front_f5_Sustain1.wav <region> key=78 sample=Koto/078078_Front_f#5_Sustain1.wav <region> key=79 sample=Koto/079079_Front_g5_Sustain1.wav <region> key=80 sample=Koto/080080_Front_g#5_Sustain1.wav <region> key=81 sample=Koto/081081_Front_a5_Sustain1.wav <region> key=82 sample=Koto/082082_Front_a#5_Sustain1.wav <region> key=83 sample=Koto/083083_Front_b5_Sustain1.wav <region> key=84 sample=Koto/084084_Front_c6_Sustain1.wav ``` ## スケッチ メインコアのプログラム (スケッチ) を示します。上述の```Kompacto```クラスと```SFZSink```クラスを連結して動かすだけのシンプルなものになっています。 ```cpp:main.cpp #include <SFZSink.h> #include "Kompacto.h" // 鍵盤のキー1~8のピン番号 static const int PIN_KEYBOARD[] = { PIN_D00, PIN_D01, PIN_D02, PIN_D03, PIN_D04, PIN_D05, PIN_D06, PIN_D07 }; // 各弦の判定周波数[Hz] (音階とは特に関係なく、調整しやすい周波数でよい) static const float FREQ_STRINGS[]={ 1000.0F, // G線 1500.0F, // D線 2000.0F, // A線 2500.0F, // E線 }; // 琴のSFZデータを指定してSinkを生成 SFZSink sink("Koto.sfz"); // 定数とSinkを指定して楽器を生成 Kompacto inst(PIN_KEYBOARD, FREQ_STRINGS, sink); // 初期化 void setup() { // 楽器の初期化 if (!inst.begin()) { Serial.println("ERROR: init error."); while (true) delay(1000); } } // メインループ void loop() { // 楽器の更新 inst.update(); } ``` GitHubリポジトリ: https://github.com/lipoyang/Kompacto # 弦楽器パーツ - ピックアップは前述のとおり、4弦エレキギター用のものを使用しました。 - ペグはウクレレ用フリクションペグ (GOTOH UKB-N) を使用しました。(小型化のため) - ブリッジ部はMDFとネジで作りました。ブリッジピンのかわりにM4ネジを使用しています。 - ナット部もMDFとネジで作りました。 - 弦は、エレキギター弦 (D'Addario EXL110) の1弦から4弦を使用しています。 ![キャプションを入力できます](https://camo.elchika.com/185ca24227734480afa86b46a9bb62815373ebf3/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f37396634626532652d356265312d346163332d393831612d346634623832326430323464/) ペグは裏側から調整できます。 ![キャプションを入力できます](https://camo.elchika.com/3d4d3ade366517a260c5b088edd7fa072612e901/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f35383437313635632d633236622d343437322d393064312d333033373462333336633636/) # 追加機能 弦の長さがわずか10cm程度と短く、また小型化のためギアペグではなくフリクションペグを採用したため、チューニング (周波数の調整) が非常に困難で、またチューニングがズレやすいという問題がありました。4本の弦のどれが弾かれたかさえ判定できればよいため、各弦の周波数を記憶するキャリブレーション機能を実装することにしました。しかし、そのためには何らかのUIが必要になります。そこで、1.3インチ OLEDディスプレイとスイッチ付きロータリーエンコーダを追加しました。このようなUIがあれば、将来的な機能追加も容易になります。 ## 追加したハードウェア |名称|型番・備考| |---|---| |ロータリーエンコーダ |2相, 24パルス, φ6mm軸, スイッチ付き| |OLEDディスプレイ| 1.3インチ 128x64 ピクセル (白) コントローラ: SH1106 (I2C接続)| ロータリーエンコーダのA相、B相、スイッチ端子は、SPRESENSE拡張ボードの D08、D09、D10端子に接続しました。また、OLEDディスプレイのSCL、SDA端子は、SPRESENSE拡張ボードの SCL(D15)、SDA(D14) 端子に接続しました。 ## 追加したソフトウェア ### (1) ロータリーエンコーダ入力 スイッチ付きロータリーエンコーダ入力のために、```SwEncoder```クラスを作成しました。A相/B相のパルスによる外部割り込みを使ってロータリーエンコーダの変位をカウントし、またスイッチの短押し/長押しを判定します。 ```cpp:SwEncoder.h #pragma once #include <stdbool.h> // スイッチ付きロータリーエンコーダ class SwEncoder{ public: void begin(int pinA, int pinB, int pinSW = -1); // ピン番号を指定して開始 void update(); // 更新 void reset(); // エンコーダのリセット int readCount(); // エンコーダの値を読む int readDiff(); // エンコーダの変化を読む (前回からの差分) bool isPressed(); // スイッチが押されているか? bool wasReleased(); // スイッチが短押しで離されたか? bool wasLongPushed(); // スイッチが長押しされたか? static void isr(SwEncoder *encoder); private: int _pinA; int _pinB; int _pinSW; bool _has_sw; int _cnt; int _cnt_prev; bool _sw_prev; bool _wasReloased; bool _initializing; bool _wasLongPushed; bool _wasLongPushed2; uint32_t _t_pushed; uint32_t _t_last; }; ``` ```cpp:SwEncoder.cpp // スイッチ付きロータリーエンコーダ (割り込み使用) #include <Arduino.h> #include "SwEncoder.h" #define MAX_SW_ENCODER 4 // インスタンスは最大4個まで static SwEncoder *_sw_encoder[MAX_SW_ENCODER]; static int _sw_encoder_index = 0; static void isr0(){ SwEncoder::isr(_sw_encoder[0]); } static void isr1(){ SwEncoder::isr(_sw_encoder[1]); } static void isr2(){ SwEncoder::isr(_sw_encoder[2]); } static void isr3(){ SwEncoder::isr(_sw_encoder[3]); } static void (*isr_table[MAX_SW_ENCODER])() = { isr0, isr1, isr2, isr3 }; void SwEncoder::isr(SwEncoder *encoder) { if(encoder->_initializing) return; // trick if(digitalRead(encoder->_pinA) == digitalRead(encoder->_pinB)){ encoder->_cnt++; }else{ encoder->_cnt--; } } void SwEncoder::begin(int pinA, int pinB, int pinSW) { _pinA = pinA; _pinB = pinB; _pinSW = pinSW; _cnt = 0; _cnt_prev = 0; _sw_prev = false; _has_sw = (pinSW > 0) ? true : false; _wasReloased = false; _wasLongPushed = false; _wasLongPushed2 = false; pinMode(pinA, INPUT_PULLUP); pinMode(pinB, INPUT_PULLUP); if(_has_sw){ pinMode(pinSW, INPUT_PULLUP); } delay(100); _initializing = true; // trick if(_sw_encoder_index < MAX_SW_ENCODER){ _sw_encoder[_sw_encoder_index] = this; attachInterrupt(pinA, isr_table[_sw_encoder_index], RISING); _sw_encoder_index++; } _initializing = false; // trick _t_last = millis(); } void SwEncoder::update() { uint32_t now = millis(); uint32_t elapsed = _t_last - now; if(elapsed > 100){ _t_last = now; if(_has_sw) { bool sw = (digitalRead(_pinSW) == LOW) ? true : false; if((_sw_prev == true) && (sw == false)){ if(_wasLongPushed2){ _wasLongPushed2 = false; }else{ _wasReloased = true; } } if((_sw_prev == false) && (sw == true)){ _t_pushed = now; } if(sw == true){ if((_wasLongPushed2 == false) && (now - _t_pushed > 3000)){ _wasLongPushed = true; _wasLongPushed2 = true; } } _sw_prev = sw; } } } void SwEncoder::reset() { _cnt = 0; _cnt_prev = 0; } int SwEncoder::readCount() { return _cnt; } int SwEncoder::readDiff() { int val = _cnt - _cnt_prev; _cnt_prev = _cnt; return val; } bool SwEncoder::isPressed() { bool ret = (_has_sw && digitalRead(_pinSW) == LOW) ? true : false; return ret; } bool SwEncoder::wasReleased() { bool ret = _wasReloased; _wasReloased = false; return ret; } bool SwEncoder::wasLongPushed() { bool ret = _wasLongPushed; _wasLongPushed = false; return ret; } ``` ### (2) OLEDディスプレイ出力 OLEDディスプレイ出力のために```Display```クラスを作成しました。メインコアの負荷を減らすため、OLEDディスプレイとの通信はサブコア2に任せ、メインコア側はサブコア2への指令のみ実行するようにします。これにより、全体で3コアを使用することになりました。SPRESENSEのマルチコアを活かした作りになったと思います。 ```cpp:Display.h #pragma once #include <iostream> #include <cstdarg> // 表示コマンドデータ構造体 struct DisplayData{ int command; char data[3][11]; }; static const int CMD_NORMAL = 0; // 通常表示 static const int CMD_LARGE = 1; // デカ文字表示 // 表示処理クラス (SubCore2に指令を送る) class Display{ public: void begin(); // 開始 void update(); // 更新 bool isReady(); // レディか? void clear(); // 画面を消去する // 行を指定して書式付き文字列表示 void printf(int row, const char* format, ...) { va_list args; va_start(args, format); vsprintf(_displayData.data[row], format, args); va_end(args); } void setCommand(int command); // コマンド送信 (update時) void sendCommand(int command); // コマンド送信 (即時) private: void send(); DisplayData _displayData; DisplayData _sendData; bool _ready; bool _redraw; }; ``` ```cpp:Display.cpp // 表示処理クラス (SubCore2に指令を送る) #include <Arduino.h> #include <MP.h> #include "Display.h" // OLEDを制御するサブコアの番号 static const int SUBCORE = 2; // メッセージID static const int8_t SEND_ID = 100; // 初期化する void Display::begin() { _ready = true; _redraw = false; int ret = MP.begin(SUBCORE); if (ret < 0) { ::printf("Display: MP.begin error = %d\n", ret); } } // 更新する void Display::update() { const int OK = 200; MP.RecvTimeout(MP_RECV_POLLING); int8_t rcvid; int rcvdata; int ret = MP.Recv(&rcvid, &rcvdata, SUBCORE); if (ret < 0 && ret != -11) { ::printf("Display: MP.Recv error = %d\n", ret); }else if (ret > 0) { if(rcvid == SEND_ID && rcvdata == OK){ // printf("Display: received\n"); _ready = true; }else{ ::printf("Display: Error rcvid = %d, rcvdata = %d\n", rcvid, rcvdata); } } if(_ready && _redraw) send(); } // レディか? (描画中でないか?) bool Display::isReady() { return _ready; } // バッファのクリア void Display::clear() { _displayData.data[0][0] = '\0'; _displayData.data[1][0] = '\0'; _displayData.data[2][0] = '\0'; } // コマンド設定 void Display::setCommand(int command) { _displayData.command = command; _redraw = true; } // コマンド送信(即時) void Display::sendCommand(int command) { _displayData.command = command; send(); } // コマンド送信 (updateから呼ばれる) void Display::send() { memcpy(&_sendData, &_displayData, sizeof(DisplayData)); int ret = MP.Send(SEND_ID, &_sendData, SUBCORE); if (ret < 0) { ::printf("MP.Send error = %d\n", ret); } _ready = false; _redraw = false; } ``` サブコア2では、```Adafruit_GFX```ライブラリと```Adafruit_SH110X```ライブラリを用いて、指令された文字列をOLEDディスプレイに表示させます。 ```cpp:DisplaySub.cpp // OLEDの表示処理 #include <MP.h> #include <Adafruit_GFX.h> #include <Adafruit_SH110X.h> #include <Fonts/FreeMono9pt7b.h> #include <Fonts/FreeMono24pt7b.h> // OLEDのI2Cアドレス #define OLED_I2C_ADRS 0x3C // OLEDデバイス Adafruit_SH1106G OLED(128, 64, &Wire, -1); // 表示コマンドデータ構造体 struct DisplayData{ int command; char data[3][11]; }; static const int CMD_NORMAL = 0; // 通常表示 static const int CMD_LARGE = 1; // デカ文字表示 // エラーで停止 // num : エラーコード static void errorStop(int num) { const int BLINK_TIME_MS = 300; const int INTERVAL_MS = 1000; while (true) { for (int i = 0; i < num; i++) { ledOn(LED1); delay(BLINK_TIME_MS); ledOff(LED1); delay(BLINK_TIME_MS); } delay(INTERVAL_MS); } } // 初期化 void setup() { // マルチコア起動 int ret = MP.begin(); if (ret < 0) { errorStop(2); } // OLED初期化 if(!OLED.begin(OLED_I2C_ADRS, true)) { errorStop(5); } // OLED.display(); // delay(2000); OLED.clearDisplay(); OLED.setFont(&FreeMono9pt7b); OLED.setTextSize(1); OLED.setTextColor(SH110X_WHITE); OLED.setCursor(0,19); OLED.println(" Taisho"); OLED.println(" KOmpacTO"); OLED.display(); } // メインループ void loop() { const int OK = 200; int8_t msgid; DisplayData *rcvdata; // 受信待ち int ret = MP.Recv(&msgid, &rcvdata); if (ret < 0) { errorStop(3); } // 表示処理 OLED.clearDisplay(); if(rcvdata->command == CMD_NORMAL){ OLED.setFont(&FreeMono9pt7b); OLED.setCursor(0,19); OLED.println(rcvdata->data[0]); OLED.println(rcvdata->data[1]); OLED.println(rcvdata->data[2]); }else if(rcvdata->command == CMD_LARGE){ OLED.setFont(&FreeMono24pt7b); OLED.setCursor(30,50); OLED.println(rcvdata->data[0]); }else{ OLED.setFont(&FreeMono9pt7b); OLED.setCursor(0,19); OLED.println("ERROR"); } OLED.display(); // 応答送信 ret = MP.Send(msgid, OK); if (ret < 0) { errorStop(4); } } ``` ### (3) 周波数キャリブレーション機能
以下のような仕様の周波数キャリブレーション機能を作成します。
以下のような仕様の周波数キャリブレーション機能を実装しました。
- 起動時にSDカード内の```tuning.dat```から4本の弦の周波数の値を読み出す。 - ```tuning.dat```が存在しなければ新規作成し、既定値を書き込む。 - 起動時は通常モードとなる。 - 通常モードでは、ノートオン時にその音名 (C4など) をOLEDに表示する。 - エンコーダのスイッチを長押しするとキャリブレーションモードに入る。 - キャリブレーションモードでは、エンコーダを回すことで、どの弦のキャリブレーションをするかを選択できる。選択中の弦の名前 (G, D, A, E) とキャリブレーション値 (周波数) がOLEDに表示される。 - またキャリブレーションモードでは、弦が弾かれたときにピーク周波数をOLEDに表示する。 - スイッチを短押しすると選択中の弦のキャリブレーションを開始する。 - 弦を4回弾くと、4回のピーク周波数の平均値がキャリブレーション値に設定される。 - スイッチを長押しすると、キャリブレーション値をSDカード内の```tuning.dat```に書き込み、通常モードに戻る。
メインコアのプログラムに上記のような処理を実装しました。繁雑になるのでここではSDカードの読み書きの処理のみ示します。(ソースのリポジトリは[こちら](https://github.com/lipoyang/Kompacto)) ```cpp
```cpp:UI.cpp // UI処理
#include <Arduino.h> #include <SDHCI.h> #include <File.h>
#include "SwEncoder.h" #include "Display.h"
SDClass SD; // SDカード File myFile; // ファイル
SwEncoder encoder; // スイッチ付きロータリーエンコーダ Display display; // OLED表示 SDClass SD; // SDカード File myFile; // ファイル
extern float FREQ_STRINGS[4]; static bool _hasNoteOn = false; static bool _isNoteOn = false; static int _note; static uint32_t _t_noteOn; static float _freq; static bool _tuning_mode = false; static float _tuning_buff[4]; static int _tuning_cnt = -1; static int _tuning_str = 0; // チューニング表示 static void ui_display_tuning(float freq = 0) { const char STRING[4] = {'G', 'D', 'A', 'E'}; display.clear(); display.printf(0, "Tuning"); if(_tuning_cnt < 0){ display.printf(1, "%c = %.0f", STRING[_tuning_str], FREQ_STRINGS[_tuning_str]); }else{ display.printf(1, "%c : %d", STRING[_tuning_str], _tuning_cnt); } if(freq > 0){ display.printf(2, "%.0f", _freq); } display.setCommand(CMD_NORMAL); }
// UI処理初期化
static void ui_init()
void ui_begin()
{
// tuning.datを読み込む
// デバッグ用シリアル Serial.begin(115200); // メインボード上のLED pinMode(LED0, OUTPUT); pinMode(LED1, OUTPUT); pinMode(LED2, OUTPUT); pinMode(LED3, OUTPUT); // OLEDとロータリーエンコーダの初期化 display.begin(); encoder.begin(PIN_D08, PIN_D09, PIN_D10); // チューニング設定ファイルの読み込み
if(!SD.begin()) { Serial.println("ERROR: No SD Card"); } myFile = SD.open("tuning.dat", FILE_READ); if (myFile) { myFile.read(FREQ_STRINGS, sizeof(FREQ_STRINGS)); myFile.close(); for(int i = 0; i < 4; i++){ Serial.printf("Freq[%d] = %.0f\n", i, FREQ_STRINGS[i]); }
} // tuning.datが存在しなければ既定値を書き込む else {
} else {
myFile = SD.open("tuning.dat", FILE_WRITE); if (myFile) { Serial.println("No tuning.dat"); myFile.write((uint8_t*)FREQ_STRINGS, sizeof(FREQ_STRINGS)); myFile.close(); }else{ Serial.println("ERROR: can not create file"); } } }
// 初期化完了の表示 void ui_setupDone() { ledOn(LED2); Serial.println("START!"); display.clear(); display.printf(1, " START!"); display.sendCommand(CMD_NORMAL); delay(500); display.clear(); display.sendCommand(CMD_NORMAL); } // UI処理更新 void ui_update() { const char NOTE[12][3] = { "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" }; // エンコーダのスイッチ押された? encoder.update(); // 長押し if(encoder.wasLongPushed()){ // チューニング終了 if(_tuning_mode){ _tuning_mode = false; display.clear(); display.printf(0, "Tuning"); display.printf(1, "Completed"); display.setCommand(CMD_NORMAL); myFile = SD.open("tuning.dat", FILE_WRITE); if (myFile) { myFile.seek(0); myFile.write((uint8_t*)FREQ_STRINGS, sizeof(FREQ_STRINGS)); myFile.close(); }else{ Serial.println("ERROR: can not create file"); } } // チューニング開始 else{ _tuning_mode = true; _tuning_str = 0; _tuning_cnt = -1; ui_display_tuning(); } } // 短押し if(encoder.wasReleased()){ if(_tuning_mode){ if(_tuning_cnt < 0){ _tuning_cnt = 0; ui_display_tuning(); }else{ _tuning_cnt = -1; ui_display_tuning(); } } } // エンコーダの回転あった? int diff = encoder.readDiff(); if(diff != 0){ if(_tuning_mode){ _tuning_str = (int)((unsigned int)(_tuning_str - diff) % 4); _tuning_cnt = -1; ui_display_tuning(); } } // ノートオンあった? if(_hasNoteOn){ _hasNoteOn = false; if(_tuning_mode == false) _isNoteOn = true; if(display.isReady()){ if(_tuning_mode == false){ // 通常モード if(_note > 0){ int note12 = _note % 12; int octave = _note / 12 - 1; display.clear(); display.printf(0, "%s%d", NOTE[note12], octave); display.setCommand(CMD_LARGE); } }else{ // チューニングモード if(_tuning_cnt >= 0){ _tuning_buff[_tuning_cnt] = _freq; _tuning_cnt++; if(_tuning_cnt >= 4){ float ave = 0; for(int i = 0; i < 4; i++){ ave += _tuning_buff[i]; } ave /= 4.0; FREQ_STRINGS[_tuning_str] = ave; _tuning_cnt = -1; } } ui_display_tuning(_freq); } } } if(_isNoteOn){ uint32_t now = millis(); uint32_t elapsed = now - _t_noteOn; if(elapsed > 1000){ _isNoteOn = false; if(display.isReady()){ display.clear(); display.setCommand(CMD_LARGE); } } } // OLED表示更新 display.update(); } // UI処理 (ノートオン時) void ui_onNoteOn(int note, float freq) { _note = note; _freq = freq; _t_noteOn = millis(); _hasNoteOn = true; }
``` # 筐体 筐体は厚さ4mmのMDFで組み立てることにしました。設計には Autodesk Fusion を用いました。干渉のチェックおよびネジ穴位置のチェックのため、SPRESENSEボードの3Dモデル ([こちら](https://developer.sony.com/spresense/development-guides/hw_design_ja.html)からダウンロード) を取り込んで利用しました。天板は弦の張力に耐えられるように、MDFを2枚重ねにしたうえでアルミ材で補強します。 ![3Dモデルの設計](https://camo.elchika.com/10dbfa93ce1c315637dbfb6cf468d67f9b936f74/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f62323561643063662d653762632d346662362d386436342d346138346535383934623835/) レーザー加工用のデータを作るため、部品ごとにスケッチを作成して「プロジェクト」で投影図を作成したのち、スケッチを「DXF形式で保存」します。部品ごとのDXFデータができたら、それらを1枚のMDFから切り出せるように Inkscape 上で並べます。 ただし、スプライン曲線を含むスケッチをDXF形式で保存してInkscapeで開くとスプライン曲線が表示されません。Autodesk Fusionは5次のスプラインを用いていますが、Inkscapeなど多くのソフトでは3次のスプラインしかサポートしていないためです。そこで、Pythonスクリプトを用いてスプラインをポリラインに変換しました。使用したPythonスクリプトについては[こちらの記事](https://lipoyang.hatenablog.com/entry/2024/09/02/141048)を参照してください。 ![レーザー加工用データ](https://camo.elchika.com/26645e00f46ba873b11c3e1ed4bf53d9d6d89697/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f30393165346131372d343838362d343264652d396537392d396165353835653836333830/) 今回、レーザー加工は [Anymany](https://anymany.net/) 様にお願いしたので、データの書式はそちらの指定に従いました。切り出された部品は仮組みして確認したのち、木工用ボンドで固定します。 ![MDFをカットして組み立て](https://camo.elchika.com/8e3f57f9d8759a1a5c22f952a64885d1520a6f21/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f38346138626234622d633938312d343634312d613765632d653631393963303730653930/) MDFは塗料が染み込みやすいので、まずサンディングシーラーを刷毛塗りして研磨し、表面を樹脂化します。塗装はラッカー塗料の缶スプレーを用い、半光沢仕上げにしました。私は気管支が弱いので、塗装の際は塗装ブースと[防塵・有機ガス用マスク](https://www.monotaro.com/p/1961/3913/)を使用します。 ![塗装](https://camo.elchika.com/6782ff93d3d05b65dfea82c690658134c0a647ea/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f62386365366638372d363034302d346466302d613763652d376630613038633939343839/) 塗装ができたら電装系とペグ等を取り付けて組み立てます。 ![電装系の取り付け](https://camo.elchika.com/297a019f2673512a249fb82e3b7844ceb4be8d15/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f37336139666131642d376337642d346235342d613331632d656237653032343062383239/) 弦を張ったら完成です。 ![完成](https://camo.elchika.com/e7fe8c6f0194e24ead2489f3d22270b797871a83/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f65363836663263622d316331322d343731392d623966382d3934363239653238386636312f35393666656333662d336536332d346437372d393461302d633134373435343232663662/) # 課題 - 5~6本の弦が同時に鳴る大正琴と比べると音の厚みが欠けるので、同時発声できるようにしたい。 - モード切り替えで、自動トレモロ奏法ができるようにしたい。(キーを押している間はトレモロ奏法の音を再生) - ふつうの大正琴より演奏が難しい。(イベント展示に備えて練習が必要。) - 次に弾くべきキーと弦をLEDで光らせるガイド機能がほしい。(曲データはSDカードに保存) - どのキーと弦を弾いても正しい音を鳴らしてくれるモードもほしい。