[自作キーボード]QMKファームウェアで音声ガイダンスを再生する~DFPlayer Mini互換機(MP3-TF-16P)と連携~

目次

Abstract

  • QMK環境でDF Player Miniを使って、キーボードの状態に合わせて音声ガイダンスを再生させてみた

Introduction

今回はQMKファームウェアで音声ガイダンスを再生できるようにしてみたいと思います。QMKにはAudio機能がありますが、そちらはキー押下時のビープ音とか単純な音の再生しかできないようです。今回やりたいのはワイヤレスイヤホンとかでよくある「起動しました」、「モード切り替えしました」とかの音声ガイダンスなので、QMKのAudio機能ではなく、MP3プレイヤーモジュールと連携することで機能を実現したいと思います。

Preparation

MP3プレイヤーモジュールの選定

MP3プレイヤーモジュールで有名なのはDF Robotが出しているDF Playerのシリーズが有名です。フラッシュ内蔵のPro/MP3 Voice Module、MicroSDリーダが付いたminiなどありますが、安いですしmicroSDのほうが何かと便利そうなので、DF Player Miniを使いたいと思います。といっても正規品は1個1000円くらいで少し高いので今回は1個200円くらいのMP3-TF-16Pという互換品モジュールを使用します。 互換品の方は専用のデータシートとかないので、以下には正規品モジュールの概要載せておきます。

Maker Parts Number 価格 製造元ページ データシート Wiki
DF Robot DF Player Mini 約1000円 Link ←のページ内にリンク有り Link

MP3-TF-16Pの制御

このモジュールはmicroSDカードからのデータ読み込み、音声ファイル(mp3,wav)のデコードが可能でスピーカー出力用のアンプも載ってます。なので、マイコン側からは再生関連の制御指示だけ出すだけで簡単に再生できます。制御はAD制御、IO制御、UARTの3種類が選べますが、今回は一番柔軟に制御可能なUARTで制御したいと思います。

音声ガイダンスの音源の用意

個人で使うなら適当にゲームとかの音声をサンプリングしたりしてもいいですが、今回は学習なので最初から色々揃ってるやつを探してみました。昔にプロ生ちゃんのボイス配布してるのみて結構豊富だったなと思って見返してみたらシステムサウンドとか音声ガイダンス向けの音声たくさん(600種類くらい)あったので、今回はこれを使いたいと思います。(プログラミング関連の音声も多くて自作キーボード界隈とも相性良さそう。。)

RP2040とMP3-TF-16Pの接続

今回は以下のように接続しました。
PicoのGPIO0, GPIO1にUART TX, RXを割り当ててその端子でMP3-TF-16Pと接続。MP3-TF-16PのVCCは3.2~5.0VなのでRP2040のVBUS,3V3のどちらを繋いでも良さそうなので一旦VBUSに繋げてます。

Method

QMK公式のUART Driverのページを参考にして、UARTでMP3-TF-16Pの制御処理を実装していきたいと思います。
動かすまでにやることは以下です。

  1. UARTドライバコードを組み込む
  2. UART制御用の端子設定
  3. UARTの初期化処理を書く
  4. MP3-TF-16P用の送受信処理を作成する
  5. キーボードからMP3-TF-16Pを制御する処理を作成する

1. UARTドライバコードを組み込む

rules.mkに以下のコード記載することでUART制御用のコードが組み込まれます。

UART_DRIVER_REQUIRED = yes

補足:組み込まれるソースコード

UART_DRIVER_REQUIREDはbuilddefs/common_features.mkで以下のように使われており、RP2040の場合はplatforms/chibios/drivers/uart_serial.cが組み込まれるようです。

↓common_features.mk

ifeq ($(strip $(UART_DRIVER_REQUIRED)), yes)
    ifeq ($(strip $(PLATFORM)), CHIBIOS)
        ifneq ($(filter $(MCU_SERIES),RP2040),)
            OPT_DEFS += -DHAL_USE_SIO=TRUE
            QUANTUM_LIB_SRC += uart_sio.c
        else
            OPT_DEFS += -DHAL_USE_SERIAL=TRUE
            QUANTUM_LIB_SRC += uart_serial.c
        endif
    else
        QUANTUM_LIB_SRC += uart.c
    endif
endif

2. UART制御用の端子設定

UART Driverのページを見ると、UART_DRIVER, UART_TX_PIN, UART_RX_PINの3つが最低限必要そうです。※CTS/RTSによるフロー制御用はまだサポートしてないとあるので、CTS/RTS用のピン定義はしなくて良いようです。

RP2040内でUART通信に使用可能なピンはいくつかありますが、今回はUART0 TX(GP0), UART0 RX(GP1)を使用したいと思います。RP2040でのUART_DRIVERの定義値はQMKの[RP2040の説明ページ]にUART0 -> SIOD0, UART1 -> SIOD1との記載がありますので、以下のように記載します。

// UART Driver
#define UART_DRIVER SIOD0
#define UART_TX_PIN GP0
#define UART_RX_PIN GP1

RP2040の場合のデフォルトの設定は以下。↑はデフォルト設定と同じですね。
/platforms/chibios/boards/GENERIC_PROMICRO_RP2040/configs/config.h


3. UARTの初期化処理を書く

“uart.h"のインクルードとUARTドライバの初期化処理 uart_init()を呼びます。uart_init()は引数にボーレートを指定しますが、MP3-TF-16Pのボーレートは9600なのでその値を指定します。

keymap.c

#include "uart.h"

~省略~

void keyboard_post_init_user(void) {
    uart_init(9600);
}

4. MP3-TF-16P用コマンドの送受信処理を作成する

DF Playe MiniのWikiにシリアルコマンドの定義があり、送受信コマンドは以下のフォーマットになってます。

コマンドフォーマット

Index 0 1 2 3 4 5 6 7 8 9
Category Start bit Version Length Command Type Feedback Parameter 1 Parameter 2 Checksum 1 Checksum 2 End bit
Value 0x7E 0x00 0x06 0x00 or 0x01 0xEF
  • 各項目の意味
    • Start bit, Version, Length, End bit
      • 固定値なのでデータシートの値を入れるだけ
        • VersionとかLengthとか変わりそうに見えるけど、バージョンは用途不明でデータシート上は0xFF, Lengthは全コマンドで同じ長さなので0x06固定のようです
    • Feedback
      • コマンド応答が必要なら0x01、不要なら0x00にする
    • Command, Parameter 1, Parameter 2
      • Wikiやデータシートを見て使用したいコマンド、パラメータの組み合わせを入れるだけです
      • Parameter 1, 2 はそれぞれ16bitのパラメータの上位8bit, 下位8bitを入れる
    • Checksum
      • Index[1]~Index[6]までの総和を2の補数表現でマイナス値にしたもの
        • 実計算上は総和 x (-1)するだけ
      • Checksum1,2はParameter同様に上位8bit, 下位8bitを入れる

コマンド送受信用のライブラリ

上記のコマンドフォーマットを元にMP3-TF-16PとのUARTコマンド送受信用の簡易的なライブラリ(mp3tf16p.c/h)を作成しました。
APIは以下の3種類

  • 関数
    • void sendCommand(CommandType type, FeedbackType feedback, uint16_t parameter);
      • 概要
        • MP3-TF-16Pに送信したいコマンド情報を指定することで、コマンド作成・送信を実行する関数
      • 引数
        • type
          • コマンド種別
          • 種別一覧はヘッダ内でenum定義してあるのでそちらから選択して指定
        • feedback
          • 送信したコマンドに対するMP3-TF-16Pからのフィードバックコマンドを要求する/しない
            • FEEDBACK_ON: 要求する
            • FEEDBACK_OFF: 要求しない
        • parameter
          • コマンドのパラメーター
          • 上位8bit, 下位8bitへの分割は関数側で実施するので16bitで値指定する
      • 戻り値
        • 無し
    • void receiveCommand(ReceiveCallback callback);
      • 概要
        • UARTドライバのバッファを確認してMP3-TF-16Pからのコマンドがあるか確認
        • MP3-TF-16Pから受信したコマンドデータがある場合は、コールバック関数で呼び出し元に制御を渡します。
      • 引数
        • callback
          • 呼び出し元へ受信したコマンドデータを渡すためのコールバック関数
          • 関数定義は↓を参照
      • 戻り値
  • コールバック関数定義
    • typedef void (*ReceiveCallback)(const CommandType, const uint16_t);
      • 概要
        • receiveCommand()の引数に指定するようのコールバック関数の型定義
      • 引数
        • arg1
          • コマンド種別
        • arg2
          • パラメーター
      • 戻り値
        • 無し

実際に作成したソースコードは以下。折りたたんでます。

mp3tf16p.h

#pragma once
/**
 * @brief MP3-TF-16P用のコマンド種別一覧
 * 
 */
typedef enum {
    // Control Command
    NEXT = 0x01,
    PREVIOUS,
    SPECIFY_TRACK,
    VOLUME_UP,
    VOLUME_DOWN,
    SPECIFY_VOLUME,
    SPECIFY_EQ,
    SPECIFY_PLAYBACK_MODE,
    SPECIFY_PLAYBACK_SOURCE,
    ENTER_INTO_STANDBY,
    NORMAL_WORKING,
    RESET_MODULE,
    PLAYBACK,
    PAUSE,
    SPECIFY_FOLDER_TO_PLAYBACK,
    VOLUME_ADJUST_SET,
    REPEAT_PLAY,

    // Query Command
    STAY1 = 0x3c,
    STAY2,
    STAY3,
    INITIALIZEATION_PARAMETER,
    REQUEST_TRANSMISSION,
    REPLAY,
    CURRENT_STATUS,
    CURRENT_VOLUME,
    CURRENT_EQ,
    CURRENT_PLAYBACK_MODE,
    CURRENT_VERSION,
    TOTAL_NUMBER_TFCARD,
    TOTAL_NUMBER_UDISK,
    TOTAL_NUMBER_FLASH,
    KEEP_ON,
    CURRENT_TRACK_TFCARD,
    CURRENT_TRACK_UDISK,
    CURRENT_TRACK_FLASH
} CommandType;

/**
 * @brief フィードバック要求のON/OFF
 * 
 */
typedef enum {
    FEEDBACK_OFF,
    FEEDBACK_ON
} FeedbackType;

/**
 * @brief MP3-TF-16Pへのコマンド送信関数
 * 
 * @param[in] type      コマンド種別
 * @param[in] feedback  フィードバック要求のON/Off
 * @param[in] parameter コマンドパラメータ
 */
void sendCommand(CommandType type, FeedbackType feedback, uint16_t parameter);

/**
 * @brief 受信データ処理用コールバックの関数定義
 * @param[in] arg1 コマンド種別
 * @param[in] arg2 コマンドパラメータ
 * @details
 *  呼び出し元にMP3-TF-16Pから受信したコマンド種別とパラメータを返します
 */
typedef void (*ReceiveCallback)(const CommandType, const uint16_t);

/**
 * @brief MP3-TF-16Pからのコマンド受信関数
 * 
 * @param[in] callback コールバック関数
 * @details
 *  - UARTの受信バッファを確認してMP3-TF-16Pからのコマンド有無をチェックします。
 *  - 受信した場合はコマンド種別とパラメータをコールバック関数を用いて呼び出し元に返します。
 */
void receiveCommand(ReceiveCallback callback);

mp3tf16p.c

#include QMK_KEYBOARD_H
#include "uart.h"
#include "mp3tf16p.h"

// [debug]コマンド情報の出力ON/OFFマクロ(0:出力なし, 1:出力有り)
#define DEBUG_PRINT (1)
#if DEBUG_PRINT
static void printBinary(uint8_t *buf, uint16_t length)
{
    for(int i = 0; i < length; i++)
    {
        xprintf("%02x ", buf[i]);
    }
    xprintf("\n");
}
#endif

// MP3-TF-16P用のコマンドインデックス
typedef enum {
    START,
    VER,
    LEN,
    TYPE,
    FEEDBACK,
    PARA1,
    PARA2,
    CHECKSUM1,
    CHECKSUM2,
    END,
} CommandIndex;

// MP3-TF-16PのUARTコマンドの定数定義
#define CMD_LENGTH 10
const static uint8_t VAL_STARTBIT = 0x7E;
const static uint8_t VAL_VERSION = 0xFF;
const static uint8_t VAL_LENGTH = 0x06;
const static uint8_t VAL_ENDBIT = 0xEF;

// コマンド作成用関数
static uint8_t extractHighBit(uint16_t in){ return in >> 8;}
static uint8_t extractLowBit(uint16_t in){ return in & 0x00ff;}
static uint16_t combineHighLowBit(uint8_t high, uint8_t low){return ((high << 8) | low);}
static uint16_t makeChecksum(uint8_t *cmd)
{
    int16_t sum = 0;
    for(int i = VER; i <= PARA2; i++) sum += cmd[i];
    return (uint16_t)(-1 * sum);
}
static void makeCommand(CommandType type, FeedbackType feedback, uint16_t parameter, uint8_t* cmd)
{   
    cmd[START]      = VAL_STARTBIT;
    cmd[VER]        = VAL_VERSION;
    cmd[LEN]        = VAL_LENGTH;
    cmd[TYPE]       = type;
    cmd[FEEDBACK]   = feedback;
    cmd[PARA1]      = extractHighBit(parameter);
    cmd[PARA2]      = extractLowBit(parameter);

    uint16_t checksum = makeChecksum(cmd);
    cmd[CHECKSUM1] = extractHighBit(checksum);
    cmd[CHECKSUM2] = extractLowBit(checksum);
    cmd[END]       = VAL_ENDBIT;

    return;
}

void sendCommand(CommandType type, FeedbackType feedback, uint16_t parameter)
{
    uint8_t cmd[CMD_LENGTH] = {0};
    makeCommand(type, feedback, parameter, cmd);
    uart_transmit(cmd, CMD_LENGTH);
#if DEBUG_PRINT
    xprintf("TX: type[%02x] para[%04d], ", type, parameter); printBinary(cmd, CMD_LENGTH);
#endif
    return;
}

void receiveCommand(ReceiveCallback callback)
{
    if(uart_available())
    {
        uint8_t cmd[CMD_LENGTH] = {0};

        // 1byte取り出してスタートビットと一致するか確認
        cmd[START] = uart_read();
        if(cmd[START] == VAL_STARTBIT)
        {
            // 一致したら残りの9byteのデータ取得
            uart_receive(&cmd[VER], (CMD_LENGTH - 1));

            // チェックサム確認
            uint16_t readChecksum = combineHighLowBit(cmd[CHECKSUM1], cmd[CHECKSUM2]);
            uint16_t calcChecksum = makeChecksum(cmd);
            if(readChecksum == calcChecksum)
            {
                uint8_t type = cmd[TYPE];
                uint16_t parameter = combineHighLowBit(cmd[PARA1], cmd[PARA2]);

#if DEBUG_PRINT
                xprintf("RX: type[%02x] para[%04d], ", type, parameter); printBinary(cmd, CMD_LENGTH);
#endif
                // MP3-TF-16Pから受信したコマンド種別とパラメータをコールバックで呼び出し元に渡す
                callback(type, parameter);
            }else{
#if DEBUG_PRINT
                xprintf("error: checksum error. read[%02x], calc[%02x]\n", readChecksum, calcChecksum);
#endif
            }
        }
    }
    return;
}


5. キーボードからMP3-TF-16Pを制御する処理を作成する

4で作成したMP3-TF-16P用の送受信ライブラリを用いて、以下のようなことをやってみます。

  • 1.キーボードが接続された時に、接続メッセージを発話する
  • 2.特定のキーが押されたときに、そのキーの名称を発話する
  • 3.一定時間経過後、休憩を促すメッセージを発話する
  • 4.特定のキーが押された時に音量をUP/DOWNさせる
  • 5.現在の音量値をMP3-TF-16Pから取得する

音声ファイルの指定方法

MP3-TF-16Pでは、音声ファイルはSDカード内に以下のようにを保存する形式です。1番目のファイルを指定すると「0001.mp3」が再生されるといったように動作しますので、あらかじめファイル番号とその番号にどういった音声ファイルが格納されているかの対応表を作っておくと管理しやすいです。

  • SDカードルート(e.g. F:/)
    • /mp3
      • 0001.mp3
      • 0002.mp3

※ 実際にはファイル名は関係無く、ファイル作成日時のタイムスタンプ順に番号が振られるみたいなので、あくまで0001.mp3といったようなファイル名にしておけば、SDカードにファイル転送する際にその順番で転送されるため番号が一致するといった仕組みのようです。対応付がうまくいかない場合は1ファイルずつSDにコピーすると良いかもしれません。

音声ファイルの対応付け

今回は以下のように対応付けして、音声ファイルを保存しました。

No filename 対応音声の概要 対応するプロ生ちゃんボイスDBのファイル名 発話内容
1 0001.mp3 USB接続メッセージ kei_voice_087.mp3 きた! USBきたよ!
2 0002.mp3 「スペース」の発話 kei_voice_103.mp3 スペース
3 0003.mp3 「エンター」の発話 kei_voice_104.mp3 エンター
4 0004.mp3 「バックスペース」の発話 kei_voice_105.mp3 バックスペース
5 0005.mp3 休憩を促すメッセージ kei2_voice_211.mp3 ん~、ちょっと息抜きしよっか?

ソースコードの作成

1~5の内容についてkeymap.cに実装します。

keymap.c

#include QMK_KEYBOARD_H
#include "uart.h"
#include "../../mp3tf16p.h" // 実際の配置場所に合わせて記載

const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
    [0] = LAYOUT_ortho_2x3(
        KC_SPACE,   KC_ENTER,   KC_BACKSPACE,
        KC_F13,     KC_F14,     KC_F15
    )
};

// 音声ファイルのファイル番号の対応付け
enum {
    VOICE_CONNECTING = 1,
    VOICE_SPACE,
    VOICE_ENTER,
    VOICE_BACKSPACE,
    VOICE_REST,
};

void keyboard_post_init_user(void) {
    uart_init(9600);

    // 初期音量を指定
    sendCommand(SPECIFY_VOLUME, FEEDBACK_OFF, 15);

    // 1. キーボードが接続された時に、接続メッセージを発話する
    sendCommand(SPECIFY_TRACK, FEEDBACK_OFF, VOICE_CONNECTING);
}

bool process_record_user(uint16_t keycode, keyrecord_t *record)
{
    bool lRet = true;

    if (record->event.pressed) {
        switch(keycode)
        {
            // 2. 特定のキーが押されたときに、そのキーの名称を発話する
            case KC_SPACE:
                sendCommand(SPECIFY_TRACK, FEEDBACK_OFF, VOICE_SPACE);
                break;
            case KC_ENTER:
                sendCommand(SPECIFY_TRACK, FEEDBACK_OFF, VOICE_ENTER);
                break;
            case KC_BACKSPACE:
                sendCommand(SPECIFY_TRACK, FEEDBACK_OFF, VOICE_BACKSPACE);
                break;
            
            // 4. 特定のキーが押された時に音量をUP/DOWNさせる
            case KC_F13:
                sendCommand(VOLUME_UP, FEEDBACK_OFF, 0);
                break;
            case KC_F14:
                sendCommand(VOLUME_DOWN, FEEDBACK_OFF, 0);
                break;

            // 5-1. 現在音量値の取得要求を投げる
            case KC_F15:
                sendCommand(CURRENT_VOLUME, FEEDBACK_ON, 0);
                break;
            default:
                break;
        }
    }

    return lRet;
}

/**
 * @brief [コールバック関数]MP3-TF-16Pからのコマンド通知
 * 
 * @param type 
 * @param parameter 
 */
void onNotifyCommand(const CommandType type, const uint16_t parameter)
{
    switch(type)
    {
        // 5-2. 現在の音量値を受信
        case CURRENT_VOLUME:
            xprintf("==callback== CURRENT_VOLUME [%d]\n", parameter);
            break;
        default:
            break;
    }
}

static const uint16_t TIMER_RESTVOICE = 20000; // 休憩メッセージ再生までの時間[ms]
void housekeeping_task_user(void)
{
    static bool restVoiceFlag = false; // 未再生
    
    // 毎ループ受信コマンド来ているかを確認
    receiveCommand(onNotifyCommand);

    // 3. 一定時間経過後、休憩を促すメッセージを発話する
    if(!restVoiceFlag)
    {
        if(TIMER_RESTVOICE <= timer_read32())
        {
            sendCommand(SPECIFY_TRACK, FEEDBACK_OFF, VOICE_REST);
            restVoiceFlag = true; // 再生済み
        }
    }
}

Result

今回はMP3-TF-16PのUART送受信用のソースコードを別ファイル(mp3tf16p.c/h)で追加しているのでrules.mkを変更してからコンパイルします。

変更後のrules.mk

UART_DRIVER_REQUIRED = yes
SRC += mp3tf16p.c   # 追加

特に問題無くコンパイルできました。 実際に動作させている動画は以下。以下の操作を試してます。

  1. キーボードがUSB接続されたときに接続メッセージを発話
  2. Space, Enter, BackSpaceキーを押した時にキー名称を発話
  3. MP3-TF-16Pの現在の音量レベルを取得(vol: 15)
  4. 下段キーを使ってMP3-TF-16Pの音量を5レベル増加
  5. MP3-TF-16Pの現在の音量レベルを取得(vol: 20)
  6. Spaceキー押下で発話させて音量が大きくなっていることを確認
  7. USB接続から20秒経過で休憩促進メッセージを発話

Conclusion

QMK環境でDF Playr Mini(MP3-TF-16P)と連携することで、キーボードの色々な状態に応じて音声ガイダンスを再生できるようになりました。キーボードとフルカラーLCDを連携させるのは過去にやってますので、組み合わせればキーボード上に簡単なデスクトップマスコットみたいなのを作るのはできそうです。色々おもしろく作れそうで良いですね。

なお、今回は DF Playr Mini(MP3-TF-16P) を使用しましたが、キーボードに組み込むにはDIP用の基盤で少し高さが出てしままいます。表面実装しやすいもの無いかなと探したところ、AlliExpressに DY-SV19T というほぼDF Playr Miniと同じ機能で表面実装可能なものが有りました。UARTのコマンド体系は少し異なるようですが、ほぼ同じように使えるみたいなので実際にキーボードに組み込む場合はこちらを使おうかな。。

AlliExpress DY-SV19T

PC側で時刻見れるんだからRTCはいらないなと思ってましたが、デスクトップマスコット的に使うなら時刻情報をイベント管理に使いたいなぁと思ってきたので、次回はQMKとRTCをI2C辺りで連携させようと思います。



Appendix

MP3-TF-16Pの起動時ボツ音対策

以下の人の記事を参考にジャンパー(0Ω抵抗)の位置を替えてあげると鳴らなくなりました。

https://qiita.com/gilsrus/items/ed99950e9aa2c8d3eda6 https://work-now-dammit.blogspot.com/2016/08/dfplayer-mp3-module-power-onoff-clicks.html?m=1