RaspberryPi, Arduino では飽き足らない人に贈る!『PICで作るへぇボタン』

この記事は RECRUIT MARKETING PARTNERS Advent Calendar 2015 の投稿記事です。

こんにちは。2015年新卒フロントエンジニアの萬成です。アドベントカレンダーで今日と明日の記事を担当することになったので、2日とも PIC マイコンの話をしたいと思います。

突然ですが、「へぇボタンを作ってくれ!」と言われたら、あなたならどう実装しますか?

答えは、Web エンジニアなら以下の様な感じ。

new Audio('hee.wav').play();

Android エンジニアならこう。

MediaPlayer.create(context, R.raw.hee).start();

iOS エンジニアなら

if let path = NSBundle.mainBundle().pathForResource("hee", ofType:"wav") {
    var id: SystemSoundID = 0
    AudioServicesCreateSystemSoundID(NSURL.fileURLWithPath(path), &id)
    AudioServicesPlaySystemSound(id)
}

いずれもワンライナーか数行のコードで再生部分が実装できます。
上記コードのようにブラウザやスマートフォンで簡単に音を鳴らせるのは、API に音声ファイルのパスを投げれば、後の細かい処理はOSやライブラリがよしなにやってくれるからです。

では PIC で音声を再生するにはどうするのか?というのを今回は紹介したいと思います。

へぇボタン(PIC版)

PIC について

PIC には C 言語などで記述したプログラムを書き込むことが出来ます。PIC に電源を入れると、書き込まれたプログラムの通りに各ピンから入出力が行われます。
シェルで ./a.out のようにしてプログラムを走らせることは、PIC で言うと電源に PIC を繋ぐことに相当します。標準入出力は PIC の GPIO ピンの入出力に相当し、Ctrl + Cを押すことは電池を外すことに等しいです。つまり、PIC は a.out が実体化したものです。

シェル上の動作 PIC上の動作
シェルで ./a.out すると標準出力に0、1を交互に出力 PICを電池につなぐとRC0ピンにLOW、HIGHを交互に出力。
RC0ピンと電池のマイナス極の間にLEDを繋ぐとLEDが点滅する(Lチカ)

PIC に音声データを書き込む

スマホアプリではアプリをインストールした時、音声ファイルをアプリのリソースとして保持します。この記事の冒頭で書いたコードが実行されると、スマホのファイルシステムから該当の音声ファイルを探し、音声ファイルからフレームレートやビットレートなどを分析し、よしなに再生します。

では PIC ではどうなるかですが、先述の通り PIC には a.out のような実行プログラムを書き込むため、PIC の中でファイルリソースを読み込んで… みたいなことは出来ません。PIC に音声データを書き込むには、PIC のプログラム自体に音声データを持たせます。

スマホアプリとPICの音声情報の置き場所の違い
スマホアプリとPICの音声の置き場所の違い。PICは音声データをソースコード(プログラム)中に記述する

上記のように PIC のソースコード内に音声の波形データを配列として格納します。この配列は音声を .wav 形式で保存した時のデータ部から作ることが出来ます。
一般的な .wav フォーマットは音声データを圧縮せず、波形の値を前から順番に羅列する保存形式なので、バイナリエディタでデータ部のみコピーすればプログラムに音声を埋め込めます。

波形(バイナリ) 波形
hee.wav のバイナリ。赤枠がデータ部 hee.wav の先頭の波形。データ部の先頭と一致していることがわかる

音声データを削減する

スマホに容量が 1 TB のアプリをインストールすることは出来ません。端末の容量が足りませんから。それと同じように、PIC にも容量の制限があります。PIC に書き込めるプログラムのサイズは PIC によって異なり、容量の一番小さいものは 256 B のようです。今回へぇボタンに用意した PIC16F1705 は容量が 8 KB です。つまり、音声データは 8 - {へぇボタンを駆動するプログラム} KB ぐらいに削減する必要があります。
インターネットで見つけてきたへぇボタンの音声ファイルは 44100 Hz、32 bit、2 channel で 393 KB でしたので、385 KB ほど足りません。これを 16F1705 に書き込めるように波形やサンプレートなどを調整した結果、8000 Hz、8 bit、1 channel で 4 KB まで削減できました。この音声ファイルのデータ部を unsigned char[] としてソースコードに貼り付けることで、PIC に書き込むプログラムに音声データを埋め込むことが出来ます。

PIC で音声を出力する

スピーカーに繋がれた銅線を電池に繋ぐと、銅線の電池への当たり方に応じてスピーカーから「ザザザ…」という雑音が発せられます。これはスピーカーに入る電圧が断続的に変化した結果です。
スピーカーに入る電圧を音声の波形に基いて変化させれば、雑音ではなく「音」として聞こえるようになります。そのためには、先ほど配列化した音声データをなぞり、スピーカーの出力をリアルタイムに調整する必要があります。

音声データをなぞる

配列をリアルタイムになぞるにはタイマー割り込みを用います。タイマー割り込みは一定時間毎に関数を呼び出す機能で、JavaScript の setInterval のようなものです。ただし、setInterval ではスレッドの様に処理が並行に走りますが、PIC のタイマー割り込みではインターバルの関数が呼ばれると、メインの処理はインターバルの関数が終了するまで一時停止します。

今回は 8000 Hz の音声データを再生するので、タイマー割り込みの間隔は 1/8000秒、つまり0.000125秒間隔です。0.000125秒おきに配列の添字をインクリメントすれば、音声再生と同じ速度で音声データの配列をなぞれます。setInterval の発火間隔の指定は第二引数にミリ秒を指定するだけですが、PIC のタイマー割り込みの発火間隔の指定は少し複雑です。

PIC のタイマー割り込みは以下の図のように動作します。

割り込みタイマーの所要時間

タイマーを有効にすると、内部クロック4周期分の間隔でプリスケーラのカウントアップが始まります。プリスケーラの上限はいくつかある数の中から任意に選択することが出来ます。
プリスケーラがいっぱいになると、TMR0 がインクリメントされます。 TMR0 は変数なのでメイン処理から参照出来ます。TMR0 の値が 256 に達すると、割り込み処理が発生します。この時、TMR0の値はオーバーフローによって 0 に戻りますが、割り込み処理の中で TMR0 の値を設定し直すと割り込みの速度を調整出来ます。例えば割り込み処理の中で TMR0 = 128; とすれば、128 から 256 までカウントアップするようになるので、割り込みの間隔が半分になります。

上図より、割り込みにかかる時間(秒)は以下のように求まります。

T = 1 / 内部クロック(Hz) * 4 * プリスケーラ数 * (256 - TMR0の初期化値)

今回は内部クロックを 8 MHz、プリスケーラ数を 2 に設定しましたので、0.000125 = 1 / 8000000 * 4 * 2 * (256 - n) から割り込み後の TMR0 の初期化値は 131 と求まりました。が、131 だとなぜか間延びしたへぇになっていた(インターバルが長すぎた)ので調整して 135 としました。

プログラムにすると以下のようになります。

void interrupt timer(void) {
    // TMR0 の割り込み
    if (TMR0IF == 1) {
        // 次回のTMR0(③の初期値)を初期化
        TMR0 = 135;
        // TMR0 の割り込みフラグを解除
        TMR0IF = 0;
    }
}
void main() {
    // 内部クロック設定
    OSCCON     = 0b01110010; // 8 MHz
    // OSCCON  = 0b01111010; // 16 MHz
    // OSCCON  = 0b01101010; // 4 MHz
    // OSCCON  = 0b01100010; // 2 MHz
    // OSCCON  = 0b01011010; // 1 MHz
    // プリスケーラ設定(今回は 2 なので未指定)
    // OPTION_REG = 0b00000000; // 2
    // OPTION_REG = 0b00000001; // 4
    // OPTION_REG = 0b00000010; // 8
    // :
    // OPTION_REG = 0b00000111; // 256
    // 割り込みを許可
    PEIE       = 1;
    GIE        = 1;
    // TMR0(③の初期値)を初期化
    TMR0       = 0;
    // 割り込みフラグを解除
    TMR0IF     = 0;
    // 割り込み開始
    TMR0IE     = 1;
    while (1) {}
}

出力を調整する

PIC16F1705 には 8bit(出力が256段階)のデジタル・アナログ変換(DAC)が内蔵されていますのでこれを使います。用意したへぇ音のビットレートもちょうど 8bit なので情報の欠損なく出力できそうです。16F1705 にはオペアンプ(OPA)も内蔵されており、DAC の出力を OPA に繋ぐと、より大きい音を出すことが出来ます。せっかくなので今回はこの機能も使い、DAC + OPA でスピーカーにかかる出力を調整してみます。

DAC を使うには、まず DAC1CON0 に設定を書き込みます。

DAC1CON0 の設定ビット
bit 設定内容
7 DAC1EN DAC を有効にする場合は 1
6 -
5 DAC1OE1 DAC の出力を DAC1OUT1 ピンから出力する場合は 1
4 DAC1OE2 DAC の出力を DAC1OUT2 ピンから出力する場合は 1
3 DAC1PSS DAC の電源選択ビット。FVR Buffer2: 0b10、VREF+: 0b01、VDD: 0b00
2
1 -
0 DAC1NSS DAC のグランド選択ビット。VREF-: 1、VSS: 0

今回は DAC の出力は OPA 経由、電源・グランドは VDD / VSS から取るので、

DAC1CON0 = 0b10000000;

となります。この状態で DAC1CON1 = 128; とすると、最大出力の半分が出力されます。DAC1OEn が 1 の場合は DAC1OUTn ピンから出力されますが、今回は DAC に接続された OPA から出力します。

OPA も以下のとおり OPA1CON で設定します。

OPA1CON の設定ビット
bit 設定内容
7 OPA1EN OPA を有効にする場合は 1
6 OPA1SP 速度/消費電力選択ビット。高 GBWP モード: 1、低 GBWP モード: 0
5 -
4 OPA1UG ユニティゲイン選択ビット。 OPA 出力を反転入力に接続: 1、反転入力を OPA1IN ピンに接続: 0
3 -
2 -
1 OPA1PCH 非反転入力選択ビット。FVR Buffer2 に接続: 0b11、DAC_output に接続: 0b10、OPA1IN+ ピンに接続: 0b00
0

音声出力では OPA1SP は高 GBWP モードでないと雑音が酷いです。OPA1IN は使わないため OPA1UG は 1、入力をDAC の出力に繋ぐので OPA1PCH は 0b10となり、OPA の設定は以下のようになりました。

OPA1CON = 0b11010010;

まとめると以下のようになり、これを実行すると OPA1OUT ピンから最大出力の半分の出力が出ます。

DAC1CON0   = 0b10000000;
OPA1CON    = 0b11010010;
DAC1CON1   = 128;

前述の 0.000125秒おきに呼び出される割り込みタイマーの中で、音声データ配列の値を順番に DAC1CON1 に代入すると、元の音声と同じ速度・音量でスピーカーから音がなります。

まとめ

PIC を使ったLチカの発展的課題の解法を紹介しました。この記事を読んで PIC に興味を持たれた方がいれば幸いです。

この記事の冒頭で「へぇボタンを作ってくれ!」と言われたらどう実装するかと問いましたが、PIC エンジニアの僕ならこう答えます。

// main.h
#include <xc.h>
const unsigned char wav[] = {0x80,0x7f,0x7e,0x81,...};
// main.c
#include <xc.h>
#include <htc.h>
#include "main.h"
#define _XTAL_FREQ 8000000
#pragma config FOSC     = INTOSC
#pragma config WDTE     = OFF
#pragma config PWRTE    = OFF
#pragma config MCLRE    = ON
#pragma config CP       = OFF
#pragma config BOREN    = ON
#pragma config CLKOUTEN = OFF
#pragma config IESO     = OFF
#pragma config FCMEN    = OFF
#pragma config WRT      = OFF
#pragma config PLLEN    = OFF
#pragma config STVREN   = ON
#pragma config BORV     = HI
#pragma config LVP      = OFF
const int length = sizeof(wav) / sizeof(wav[0]);
int position;
void interrupt timer(void) {
    if (TMR0IF == 1) {
        TMR0 = 135;
        TMR0IF = 0;
        if (position < length) {
            DAC1CON1 = wav[position++];
        }
    }
}
void main() {
    OSCCON     = 0b01110010;
    ANSELA     = 0b00000000;
    ANSELC     = 0b00000000; 
    TRISA      = 0b00000000;
    WPUA       = 0b00000000;
    TRISC      = 0b00100000;
    WPUC       = 0b00100000;
    OPTION_REG = 0b00000000;
    PORTA      = 0;
    PORTC      = 0;
    PEIE       = 1;
    GIE        = 1;
    DAC1CON1   = 0;
    DAC1CON0   = 0b10000000;
    OPA1CON    = 0b11010010;
    position   = length;
    TMR0       = 0;
    TMR0IF     = 0;
    TMR0IE     = 1;
    while (1) {
        if (RC5 == 0) {
            position = 0;
            while (RC5 == 0);
            __delay_ms(100);
        }
    }
}

へぇボタン回路図
回路図

Github: manse/hee-button

References