見出し画像

プログラムと電子工作・楽曲演奏(4)割込み処理でDAC出力

楽曲演奏(3)では、楽曲データを平場(loop() 関数内)でデジタル・アナログ変換(DAC)して出力しました。この方法では演奏速度が CPUクロック周波数によって変化します。

今回の楽曲演奏(4)では、DACの周期をタイマ割込みで発生し、割込み処理で DAC出力します。この方法では演奏速度が CPUクロック周波数などの処理系に依存しません。その他は、楽曲演奏(3)と同様です。

割込み処理とは、人の仕事を例に取ると、書類を作成中に電話がかかってきた時、書類作成を一時中断して電話の対応後、また書類作成に戻るというような状況です。書類の作成が平場(loop() 関数内)で、電話応対が割込み処理です。

人の仕事でもそうですが、割込み処理が長すぎると本来の仕事ができなくなります。M5StickC Plus でも割込み処理時間が数10マイクロ秒を超えるとリブートします。割込みでできる処理は短時間で終わる処理だけです。

曲目はヴィヴァルディの「四季」より「春」の冒頭部分 8秒間です。


目標

  • 外部接続した SpeakerHAT で音を鳴らします。

  • ヴィヴァルディの「四季」より「春」の冒頭部分を演奏します。

部品・機材

使用する部品は次のとおりです。SpeakerHAT を使用します。

電子部品

開発用機材

  • PC(Windows10 または 11)、開発環境 Arduino-IDE 導入ずみ

  • USB-A・USB-C ケーブル

開発手順

  1. M5StickC Plus に SpeakerHAT を接続する。

  2. PC と M5StickC Plus を USBケーブルで接続する。

  3. Arduino-IDE でスケッチ dacwrite2.ino を開く。

  4. 同じフォルダに vivaldi_1ch_16000hz_uint8.h を保存する。

  5. 検証・コンパイルする。

  6. M5StickC Plus に書き込む。

  7. 上ボタン(押しボタンA)を押して、演奏を開始する。

  8. 曲が流れることを確認する。

スケッチ

dacwrite2.ino

#include <M5StickCPlus.h>
#include "vivaldi_1ch_16000hz_uint8.h"
#define SAMPLING_FREQ 16000
#define VOLUME 11  /*音量、0~11 最大*/

#define SPEAKER_PIN GPIO_NUM_26  /*SpeakerHAT 26ピン*/

//  割込み設定
#define ID_TIMER_0 0
#define COUNTUP true
#define EDGE_TRIGGER true
#define AUTORELOAD true

hw_timer_t* timer = nullptr;

//  演奏パラメータ
volatile const uint8_t* music_data_ptr; /*楽曲データポインタ*/
volatile uint32_t music_data_remained;  /*楽曲データの残りバイト数*/
volatile uint8_t music_vol;             /*音量、最大 1、最小 11*/
volatile bool stop_music = false;  /*演奏停止要求フラグ、true 停止する*/
volatile bool is_playing = false;  /*演奏中フラグ、true 演奏中*/

bool mute = false;        /*ミュート*/

void IRAM_ATTR onTimer(void);
//------------------------------------------------------------------------------
//  setup()
void setup() {
    //  電源ON時に 1回だけ実行する処理をここに書く。
    M5.begin();               /*M5を初期化する*/
    M5.Axp.ScreenBreath(20);  /*画面の輝度を少し下げる*/
    M5.Lcd.setTextSize(3);    /*文字サイズはちょっと小さめ*/
    M5.Lcd.setRotation(3);    /*上スイッチが左になる向き*/
    M5.Lcd.println("dacwrite2");

    /*setCpuFrequencyMhz(80);*/
    /*240, 160, 80, 40, 20MHz と変えても演奏速度は変化しない。*/
    /*40MHz以下ではウォッチドッグタイマがタイムアウトして、システムがリセットする。*/

    Serial.begin(115200);   /*デバッグ用のシリアル通信を初期化する*/

    M5.Lcd.println("BtnA to start");

    pinMode(SPEAKER_PIN, OUTPUT);
}
//------------------------------------------------------------------------------
//  loop()
void loop() {
    //  自動的に繰り返し実行する処理をここに書く。
    M5.update();

    if (M5.BtnA.wasPressed()) {
        //  演奏を開始/中止する。
        if (!is_playing) {
            //  --- 演奏中ではない。
            //  ヴィヴァルディ「春」を演奏する。
            //  M5StickC Plus の場合、SPEAKER_PINが GPIO_NUM_2では動作しない。
            //  GPIO_NUM_2には DA変換機能がない。
            M5.Lcd.println("spring");
            //  ■  playMusic()関数はブロックされない。
            Serial.println("*** playMusic()");
            playMusic(wav, sizeof(wav) / sizeof(wav[0]), SAMPLING_FREQ, VOLUME);
            is_playing = true;
        }
        else {
            //  --- 演奏中である。
            //  演奏停止要求
            stop_music = true;
        }
    }
    if (M5.BtnB.wasPressed()) {
        //  音量を変える。
        if (!mute) {
            setVolume(VOLUME / 2);
            mute = true;
        }
        else {
            setVolume(VOLUME);
            mute = false;
        }
    }
    delay(1);
}
//------------------------------------------------------------------------------
//  dacwrite.ino スケッチの playMusic()以下を割込み処理に変更する。
//------------------------------------------------------------------------------
//  setVolume
//  音量を設定する。
//      in: uint8_t volume 音量、最大 11、最小 0
void setVolume(uint8_t volume)
{
    music_vol = (volume >= 11) ? 1 : 11 - volume;
}
//------------------------------------------------------------------------------
//  playMusic
//  楽曲データを演奏開始する。
//      in: const uint8_t* music_data 8ビット符号なし整数の音の振幅データ
//          const uint32_t length music_dataのバイト数
//          const uint32_t sample_rate 楽曲データのサンプリング周波数
//          const uint8_t volume 音量、最大 11、最小 0
//  ・  wavファイルの場合は、dataチャンクを 8ビット符号なし整数に変換する。
//  ・  M5StickC Plus の場合、SPEAKER_PIN は GPIO_NUM_25、GPIO_NUM_26 の 2ピンでのみ動作する。
//  ・  実験的に dacWrite()の実行時間が 20マイクロ秒かかることが判明したので、
//      delay_interval は理論値から 20を減じる。なぜこれほど処理時間がかかるのか不明。
void playMusic(const uint8_t* music_data, const uint32_t length, const uint32_t sample_rate, const uint8_t volume)
{
    uint32_t c1us = getApbFrequency() / 1000000;  /*1usec: count up/down on clock count*/
    uint32_t delay_interval = (uint32_t)1000000 / sample_rate;
    music_data_ptr = music_data;
    music_data_remained = length;
    music_vol = (volume >= 11) ? 1 : 11 - volume;

    //  タイマを開始する。
    timer = timerBegin(ID_TIMER_0, c1us, COUNTUP);
    timerAttachInterrupt(timer, &onTimer, EDGE_TRIGGER);
    timerAlarmWrite(timer, delay_interval, AUTORELOAD); /*interrupt on each music data byte*/
    timerAlarmEnable(timer); /*to stop call timerAlarmDisable()*/
}
//------------------------------------------------------------------------------
//  割込み処理
//  o  割込み処理は、楽曲のサンプリング周波数に応じた周期で起動される。
//  o  正確な周期で、dacWrite()関数を実行するので、正しい速度の演奏になる。
void IRAM_ATTR onTimer() /*IRAM_ATTR:割込み処理関数は IRAM上に配置する。*/
{
    static uint8_t last_data = 0;

    if (timer != nullptr) {
        if (!stop_music && music_data_remained > 0) {
            //  曲を演奏する。
            last_data = *music_data_ptr / music_vol;
            dacWrite(SPEAKER_PIN, last_data);
            music_data_ptr++;
            music_data_remained--;
        }
        else {
            //  フェードアウトする。
            if (last_data > 0) {
                dacWrite(SPEAKER_PIN, last_data);
                last_data--;
            }
            else {
                //  タイマを停止する。
                timerAlarmDisable(timer);
                timerDetachInterrupt(timer);
                timerEnd(timer);
                timer = nullptr;

                dacWrite(SPEAKER_PIN, 0);  /*音量を 0にする。*/
                is_playing = false;
                stop_music = false;
            }
        }
    }
    /*割込み処理は極短い処理のみ。*/
}

#include "vivaldi_1ch_16000hz_uint8.h" が、楽曲データです。const uint8_t wav[] PROGMEM = { 0x80, 0x80, 0x80, …}; という符号なし 8ビット整数の配列です。このデータは .wavファイルから python で生成しています(別の記事で紹介します)。

#define SAMPLING_FREQ 16000 は楽曲データのサンプリング周波数と一致させてください。

割込み設定

  • ID_TIMER_0:M5StickC、M5StickC Plus ではタイマ割込みは 4チャンネル使用できます。IDは 0~3です。

  • COUNTUP:タイマは、カウンタを増やす方向で使用します。

  • EDGE_TRIGGER:カウンタが設定値に達した瞬間に割込みを発生します。

  • AUTORELOAD:割込み発生後はカウンタを 0に戻して再度カウント開始します。割込みは周期的に発生します。

演奏パラメータ

  • 割込み発生ごとに 1音データずつ DAC出力します。グローバル変数に、楽曲データポインタと楽曲の残りバイト数を記録します。

  • 演奏停止要求フラグ、演奏中フラグは、割込み処理と平場(loop() 関数内)との間の情報のやり取り用変数です。明示的に「volatile」を付けてメモリ上に領域を確保します。(volatileの正確な意味は C言語の指導書を参照してください。)

playMusic()関数

playMusic()はその名に反して、楽曲データを演奏しません。割込み処理の準備を行うだけです。

割込み処理関数に渡すグローバル変数を初期化し、タイマを開始します。

onTimer()関数

楽曲を演奏します。周期的に起動されますので、曲のどこを演奏しているかを常に記憶しながら動作します。

平場(loop()関数内)の処理とは、グローバル変数を通じて情報をやり取りします。演奏を停止するときは、平場(loop()関数内)で stop_music変数を true にします。割込み処理で stop_music が true を検出したら直ちにフェードアウトを開始し、タイマを停止します。割込みが発生しなくなりますので、演奏は停止します。
演奏中かどうかを平場(loop()関数内)で知るには、is_playing変数をチェックします。

if文の thenブロックは、波形データを順番に出力する処理です。1音ずつのデータを dacWrite()で電圧 0~3.3Vに変換し、SPEAKER_PINへ出力します。データポインタを一つ進めます。

if文の elseブロックは、フェードアウト処理です。最後に出力した音量から徐々に小さくして最後に音量を 0にします。

dacWrite()の処理時間は処理系に依存しますが、割込み処理関数は一定の周期で起動するので、演奏速度は正確です。setup()内の setCpuFrequencyMhz(80); で CPUクロック周波数を変えても演奏速度は変わりません。

結果

上ボタン(押しボタンA)を押すと、曲が流れます。大きな音は出ないです。

このスケッチでは、演奏中に並行して他の処理ができます。演奏中に、上ボタン(押しボタンA)を押すと演奏が停止します。演奏中に、横ボタン(押しボタンB)を押すと音量を上げ下げできます。

写真1 dacwrite2.ino の実行結果

練習問題

  1. CPUクロックを 240, 160, 80, 40, 20MHzと変えてみて、演奏速度がどうなるか試してください。

  2. 様々な WAVEファイルを演奏してみてください。

参考

  • SpeakerHAT の回路図は、スイッチサイエンスの Webサイトに掲載されています。

  • 参考にした関数 setVolume()、playMusic() は、次のファイルで定義されています。
    C:\Users\<ユーザ>\Documents\Arduino\libraries\M5StickCPlus\src\utility\Speaker.h
    C:\Users\<ユーザ>\Documents\Arduino\libraries\M5StickCPlus\src\utility\Speaker.cpp

  • サンプルスケッチ
    Arduino-IDE メニュー→ファイル→スケッチ例→ESP32→Timer→RepeatTimer

  • タイマ関数は次のファイルに定義されています。
    C:\Users\<ユーザ>\AppData\Local\Arduino15\packages\m5stack\hardware\esp32\2.1.0\cores\esp32/esp32-hal-timer.h
    C:\Users\<ユーザ>\AppData\Local\Arduino15\packages\m5stack\hardware\esp32\2.1.0\cores\esp32/esp32-hal-timer.c

コードを解釈して整理すると、下記のようになると思います。

/**
 *
 *  o  タイマ関数一覧:
 *      hw_timer_t* timerBegin(uint8_t num, uint16_t divider, bool countUp)
 *      //  タイマを初期化する。
 *      //  return: hw_timer* タイマ構造体
 *      //      in: uint8_t num タイマ番号、03(M5StickC Plusはタイマ 4個)
 *      //          uint16_t divider 分周比、1マイクロ秒を上げ/下げするカウント数
 *      //          bool countUp true 上げ、false 下げ
 *      void timerAttachInterrupt(hw_timer_t* timer, void (*fn)(void), bool edge)
 *      //  割込み処理関数を紐付ける。
 *      //      in: hw_timer_t* timer タイマ構造体
 *      //          void (*fn)(void) 割り込みがトリガされたときに呼び出される関数
 *      //          bool edge true 上げ/下げの変化でトリガする、false 状態でトリガする
 *      void timerAlarmWrite(hw_timer_t* timer, uint64_t alarm_value, bool autoreload)
 *      //  トリガ発生条件を設定する。
 *      //      in: hw_timer_t* timer タイマ構造体
 *      //          uint64_t alarm_value トリガ発生周期(マイクロ秒)
 *      //          bool autoreload true 自動的に繰り返す、false 1回限り
 *      void timerAlarmEnable(hw_timer_t* timer)
 *      //  トリガ発生を許可する。
 *      //      in: hw_timer_t* timer タイマ構造体
 *      void timerAlarmDisable(hw_timer_t* timer)
 *      //  トリガ発生を禁止する。
 *      //      in: hw_timer_t* timer タイマ構造体
 *      void timerDetachInterrupt(hw_timer_t* timer)
 *      //  トリガ発生条件を解除する。
 *      //      in: hw_timer_t* timer タイマ構造体
 *      void timerEnd(hw_timer_t* timer)
 *      //  タイマを終了する。
 *      //      in: hw_timer_t* timer タイマ構造体
 *
 */

ライセンス

このページのソースコードは、複製・改変・配布が自由です。営利目的にも使用してかまいませんが、何ら責任を負いません。


この記事が気に入ったらサポートをしてみませんか?