ラズパイピコで音声伝言板を作成する

ラズパイピコで音声伝言板を作成する マイコン
ラズパイピコで音声伝言板を作成する

プログラムについて

プログラムは、大きく 3 つのクラスからなります。

  • VoiceMessage クラス
  • FirFilter クラス
  • SwitchHandler クラス

以下で、各クラスについて主要な処理を説明します。

VoiceMessage クラス

音声の録音・再生を行うメインとなるクラスです。

周辺機能の初期化

Initialize メソッドは、A/D コンバータ、PWM、GPIO の初期化を行っています。このメソッドはコンストラクタから呼び出されます。

Raspberry Pi Pico 2 には D/A コンバータが搭載されていないため、代わりに PWM (パルス幅変調) ユニットを用いて出力します。出力はパルス列になりますが、アナログ フィルターを通すことにより、最終的にアナログ波形に変換されます。

C++
void VoiceMessage::Initialize()
{
    // ADC の初期化
    adc_init();
    adc_gpio_init(ADC_GPIO_NUM); // ADC_GPIO_NUM を ADC0 に設定
    adc_select_input(0);         // ADC0 を選択

    // PWM の初期化
    gpio_set_function(PWM_GPIO_NUM, GPIO_FUNC_PWM);         // GPIO を PWM 出力に設定
    uint pwmSliceNum = pwm_gpio_to_slice_num(PWM_GPIO_NUM); // GPIO のスライス番号を取得
    pwm_set_wrap(pwmSliceNum, PWM_WRAP_VALUE);              // PWM のラップ値を設定 (10 ビット相当)
    pwm_set_clkdiv(pwmSliceNum, 1.0f);                      // クロック ディバイダを設定 (1.0f = 1/1)

    // GPIO の初期化
    gpio_init(PICO_DEFAULT_LED_PIN);              // LED ピンの初期化
    gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT); // LED ピンを出力に設定
    gpio_init(MPX_GPIO_NUM);                      // マルチプレクサ出力ピンの初期化
    gpio_set_dir(MPX_GPIO_NUM, GPIO_OUT);         // マルチプレクサ出力ピンを出力に設定
}

ピン アサインは、以下の通りとなっています。

機能GPIO 番号ピン番号
A/D コンバータ入力2631
PWM 出力01
マルチプレクサ制御出力12
録音スイッチ入力24
再生スイッチ入力35
停止スイッチ入力46
Raspberry Pi Pico 2 のピン アサイン

タイマー初期化

StartTimer メソッドは、サンプリング タイミングとなる 31 μs (≒ 1 / 32 kHz) のタイマーを作成しています。ディレイ引数を負の値 (-TIMER_PERIOD_US) にしているのは、タイマーのディレイ間隔を、コールバック呼び出しの開始から次のコールバック呼び出しの開始とするためです (正の値にすると、コールバック呼び出しの終了から次のコールバック呼び出しまでの間隔と解釈され、正確に 31 μs 間隔のタイミングになりません)。

コールバック関数として、VoiceMessage クラスの静的メソッドである TimerCallback メソッドを指定しています。また、コールバック関数から VoiceMessage クラスのインスタンス メソッドを呼び出せるように、ユーザー データとして this ポインターを渡しています。

C++
bool VoiceMessage::StartTimer()
{
    if (_timerStarted)
    {
        // タイマーが既に開始されている場合は、何もしません。
        return true;
    }

    // タイマーの初期化
    _timerStarted = add_repeating_timer_us(-TIMER_PERIOD_US, TimerCallback, this, &_timer);
    return _timerStarted;
}

タイマー コールバック処理

TimerCallback メソッド内部では、引数として渡されてきたユーザー データを VoiceMessage クラスのポインター型へキャストし、OnTimer メソッドを呼び出します。タイマー コールバックの処理本体は、OnTimer メソッド内に実装されています。

OnTimer メソッドの内部では、音声データの録音・再生処理を行っています。

C++
bool VoiceMessage::TimerCallback(repeating_timer_t *rt)
{
    // タイマー コールバックの処理
    VoiceMessage *instance = static_cast<VoiceMessage *>(rt->user_data);
    instance->OnTimer();
    return true; // true を返すと、タイマーは再度呼び出されます
}

録音開始処理

StartRecording メソッドでは、以下の処理を行います。

  1. _isRecordingMode フラグを true に設定します。
  2. 音声データをクリアします。
  3. 共通の開始処理を呼び出します。

なお、すでに _isBusy フラグが true である場合は、何も行いません。

C++
bool VoiceMessage::StartRecording()
{
    if (_isBusy) // 既に処理中の場合は録音を開始しない
    {
        return false; // 処理中の場合は false を返す
    }

    _isRecordingMode = true; // 録音モードに設定
    _audioBuffer.clear();    // 音声データのバッファをクリア

    return StartProcessing(); // タイマーを開始して録音を開始
}

再生開始処理

StartPlayback メソッドでは、以下の処理を行います。

  1. _isRecordingMode フラグを false に設定します。
  2. サンプルのインデックスを 0 にリセットします。
  3. PWM を有効化します。
  4. 共通の開始処理を呼び出します。

なお、すでに _isBusy フラグが true である場合または、再生するデータが空の場合は、何も行いません。

C++
bool VoiceMessage::StartPlayback()
{
    if (_isBusy) // 既に処理中の場合は再生を開始しない
    {
        return false; // 処理中の場合は false を返す
    }

    if (_audioBuffer.empty()) // バッファが空の場合は再生できない
    {
        return false; // バッファが空の場合は false を返す
    }

    _isRecordingMode = false;            // 再生モードに設定
    _sampleIndex = 0;                    // サンプルのインデックスをリセット
    pwm_set_enabled(_pwmSliceNum, true); // PWM を有効化

    return StartProcessing(); // タイマーを開始して再生を開始
}

録音・再生開始処理

StartProcessing メソッドは、録音・再生共通の開始処理で、以下の処理を行います。

  1. アナログ マルチプレクサ切り替え用の信号を出力し、安定するまで少し待機します。
  2. タイマーをスタートします。
  3. _isBusy フラグを true にします。
  4. インジケーター用 LED を点灯させます。
C++
bool VoiceMessage::StartProcessing()
{
    gpio_put(MPX_GPIO_NUM, _isRecordingMode); // マルチプレクサの出力を設定
    sleep_ms(1);                              // マルチプレクサの設定が反映されるまで待機

    if (!StartTimer()) // タイマーを開始
    {
        return false; // タイマー開始に失敗した場合は false を返す
    }

    _isBusy = true;                    // 処理中フラグをセット
    gpio_put(PICO_DEFAULT_LED_PIN, 1); // LED を点灯
    return true;
}

停止処理

Stop メソッドは録音・再生共通の処理で、以下の処理を行います。

  1. タイマーを停止します。
  2. _isBusy フラグを false にします。
  3. 現在のサンプル数のカウントを 0 にリセットします。
  4. ローパス フィルターをリセットします。
  5. PWM を無効化します。
  6. インジケーター用 LED を消灯します。
C++
void VoiceMessage::Stop()
{
    // 録音/再生を停止する処理
    StopTimer();                          // タイマーを停止
    _isBusy = false;                      // 処理中フラグをリセット
    _sampleCount = 0;                     // 現在のサンプル数をリセット
    _lowpassFilter.Reset();               // フィルターの状態をリセット
    pwm_set_enabled(_pwmSliceNum, false); // PWM を無効化
    gpio_put(PICO_DEFAULT_LED_PIN, 0);    // LED を消灯
}

録音処理

_isRecordingMode 変数が true の時は、OnTimer メソッドで録音処理が行われます。処理の流れは以下の通りです。

  1. A/D コンバータで、入力信号をサンプリングします。
  2. A/D コンバータの変換値は 0 ~ 4095 の範囲であるので、2048 を引いて、-2048 ~ 2047 の範囲に変換します (0 を中心に振幅するようにします)。
  3. ローパス フィルターを通して、4 kHz 以上の信号をカットします。
  4. サンプルされたデータからサンプルを間引き、サンプル数を 1/4 にします (サンプリング周波数を 32 kHz → 8 kHz に変換します)。
  5. -2048 ~ 2047 の範囲であったデータを、-128 ~ 127 の範囲に変換します (量子化ビット数を 8 ビットに変換します)。
  6. データをバッファに格納します。
  7. バッファがいっぱいになったら、Stop メソッドを呼び出し、録音を停止します。

上記処理 3、4 でダウン サンプリング (デシメーション) を行っています。ダウン サンプリングについて詳しくは、こちらのサイトなどを参照してください。

C++
uint16_t adcValue = adc_read(); // ADC からの値を読み取る
// ADC の値は 0 から 4095 の範囲なので、-2048 から +2047 の範囲に変換後、LPF で 4 kHz 以上をカット
float filteredValue = _lowpassFilter.Process(adcValue - 2048.0f); // フィルターを通す
// データを間引いてダウン サンプリング (デシメーション) を行う
if (_sampleCount % DECIM_RATE == 0) // DECIM_RATE サンプルごとに処理
{
    // 12 ビットから 8 ビット (-128 から +127) にスケーリング
    int16_t scaledValue = Saturate(static_cast<int16_t>(filteredValue / 16.0f), -128, 127);
    // スケーリング後の値をバッファに追加
    _audioBuffer.push_back(static_cast<int8_t>(scaledValue));
    // バッファ サイズに達したら録音を停止
    if (_audioBuffer.size() >= _audioBuffer.capacity())
    {
        Stop(); // 録音を停止
    }
}

コードの中にある Saturate というメソッドは、入力された信号を指定された上下限値内に収まるようにクリッピング (飽和演算) するためのヘルパー メソッドです。

C++
int16_t Saturate(int16_t value, int16_t min, int16_t max)
{
    return (value < min) ? min : (value > max) ? max : value;
}

再生処理

_isRecordingMode 変数が false の時は、OnTimer メソッドで再生処理が行われます。処理の流れは以下の通りです。

  1. 4 回のうち 1 回はバッファからサンプリング データを読み出し、残り 3 回は 0 データを挿入します (サンプリング周波数を 8 kHz → 32 kHz に変換します)。
  2. ローパス フィルターを通して、4 kHz 以上の信号をカットし、振幅を 4 倍します (これにより波形が補間され、きれいな波形になります)。
  3. -128 ~ 127 の範囲であったデータを、0 ~ 1023 の範囲に変換します (量子化ビット数を 10 ビットに変換します)。
  4. PWM のデューティ比に、上記データを設定します。
  5. データの最後まで再生し終わったら、Stop メソッドを呼び出し、再生を停止します。

上記処理 1、2 でオーバー サンプリングを行っています。オーバー サンプリングについて詳しくは、こちらのサイトなどを参照してください。

C++
// サンプル間に (INTERP_RATE - 1) 個の 0 データをフィルすることにより、オーバー サンプリングを行う
int8_t data; // 再生するデータ
if (_sampleCount % INTERP_RATE == 0)
{
    data = _audioBuffer[_sampleIndex]; // バッファからサンプルを取得
    _sampleIndex++;                    // サンプルのインデックスをインクリメント
}
else
{
    data = 0; // 0 データを挿入
}
// フィルターで 4 kHz 以上の信号をカットし、さらに振幅を INTERP_RATE 倍します
float filteredValue = _lowpassFilter.Process(data) * INTERP_RATE;
// フィルター後の値を 0 から 1023 の範囲にスケーリング (8 ビット → 10 ビット)
int16_t pwmValue = Saturate(static_cast<int16_t>((filteredValue + 128.0f) * 4.0f), 0, PWM_WRAP_VALUE);
// PWM のデューティ サイクルを設定 
pwm_set_gpio_level(PWM_GPIO_NUM, static_cast<uint16_t>(pwmValue)); // PWM 出力に値を設定
if (_sampleIndex >= _audioBuffer.size()) // インデックスがバッファのサイズに達した場合
{
    Stop(); // 再生を停止
}

FirFilter クラス

FIR (有限インパルス応答) 型デジタル フィルターのクラスです。今回は、音声の録音・再生時のローパス フィルターとして使用します。

フィルターのタップ数は多いほど、フィルター特性がよくなりますが、処理速度との兼ね合いでタップ数は 100 としました。

フィルター係数は FilterCoeffs.h 内に定義しており、FirFilter クラスのコンストラクタに渡しています。なお、フィルター係数は DSPLinks で生成しました (Remez 法)。フィルターのパラメーターは以下の通りです。

パラメーター
サンプリング周波数32,000 Hz
カットオフ周波数 13,500 Hz
カットオフ周波数 24,000 Hz
タップ数100
減衰率-80 dB
リップル係数0.1
フィルターのパラメーター

フィルターの処理はそれほど複雑ではないため、具体的な処理内容の説明は割愛します。FIR フィルターについて詳しく知りたい方は、こちらのサイトなどを参照してください。

DSPLinks によるフィルターの周波数特性のシミュレーション結果を、以下の図に示します。

デジタル ローパス フィルターの特性
デジタル ローパス フィルターの特性

SwitchHandler クラス

スイッチ入力を処理するクラスです。

VoiceMessage クラスのインスタンスの参照をメンバーとして保持しており、HandleSwitch メソッド内で GPIO ポートの読み取り結果に応じて、VoiceMessage クラスの対応するメソッド (録音・再生・停止) を呼び出しています。

HandleSwitch メソッドは、main 関数のループ内で 100 ms ごとに呼び出されます。

録音スイッチに関しては、誤って押して前の録音を消去してしまうことを防ぐため、1 秒以上長押しした場合に VoiceMessage::StartRecording メソッドを呼び出すようにしています。このため、録音スイッチが押下されている間、_recPressCount 変数をカウント アップし、カウントが 10 を超えた場合に VoiceMessage::StartRecording メソッドを呼び出します。また、録音スイッチが離されたときは _recPressCount 変数を 0 にリセットします。

C++
void SwitchHandler::HandleSwitch()
{
    // 録音または再生中の場合
    if (_voiceMessage.IsBusy())
    {
        // 停止用スイッチが押された場合
        if (!gpio_get(Config::SWITCH_STOP))
        {
            // 音声メッセージの録音/再生を停止
            _voiceMessage.Stop();
        }
    }
    else
    {
        // 録音用スイッチが押された場合
        if (!gpio_get(Config::SWITCH_RECORD))
        {
            // 録音スイッチの押下時間をカウント
            _recPressCount++;

            // 一定時間以上押されている場合は録音を開始
            if (_recPressCount > REC_PRESS_MAX) // 100 ms * 10 回 = 1000 ms
            {
                // 音声メッセージの録音を開始
                _voiceMessage.StartRecording();
            }
        }
        // 再生用スイッチが押された場合
        else if (!gpio_get(Config::SWITCH_PLAYBACK))
        {
            // 音声メッセージの再生を開始
            _voiceMessage.StartPlayback();
        }
    }

    // 録音用スイッチが離された場合、押下時間カウントをリセット
    if (gpio_get(Config::SWITCH_RECORD))
    {
        _recPressCount = 0;
    }
}

ソース コード

ソース コード一式は、GitHub にて公開しています。