ねんどろいど伊401に時報を喋らせるガジェットを作ってみた

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

こんにちは。2015年新卒フロントエンジニアの萬成です。
RaspberryPi や Arduino はとても扱いやすく便利ですが、端末自体の大きさや電源の問題からどうしてもガジェットが大きくなってしまうのが実情です。そこで今回は、小さくてボタン電池でも駆動できる PIC を使い、ねんどろいど伊401に時報を実装してみました。

背景

ブラウザゲーム「艦隊これくしょん -艦これ-」には一部のキャラクターに時報が実装されています。時報とはゲームのホーム画面で毎時0分を迎えた時、キャラクターのボイスで現在時刻と、その時間に関するセリフを喋ってくれる便利な機能です。ただ、ゲームのホーム画面を開いていないと時報が聞けないのがネックです。

母港
艦これのホーム画面。毎時0分にこの画面を開いていると時報が聞ける

ある日「ねんどろいどが時報を喋ったらテンション上がるんじゃね?」というインスピレーションが湧いたので、作ることにしました。1時間毎に24個ある時報音声を順番に再生するガジェットです。この手の工作は RaspberryPi や Arduino などを使うのが普通ですが、これでは喋ってる感が出せません。ねんどろいどの頭部は各辺2.5cmほどの空間があり、その中にガジェットを入れたほうが喋ってる感が出てテンションが上ります。そこで、この空間に入る大きさのデバイスを RaspberryPi などより圧倒的に小さい PIC を使って実装することにしました。

bad good
RaspberryPi で実装した場合。機能は豊富だがデカい PIC で実装した場合。頭部パーツに収まるほど小さく実装できる

課題と解決策

昨日のアドベントカレンダーの記事「PICで作るへぇボタン」では、PIC に書き込むプログラムに音声データを持たせることで音声を再生しました。では今回も同じ方法で出来るかというと、そうは行きません。前回のへぇ音はたかだか2秒程度の音声だったのに対し、今回の時報は数秒間の時報音が24個あり、どうしても PIC に書き込めない容量になってしまいます。そこで、音声を micro SD カードに入れ、PIC から micro SD カードの中に入っている音声ファイルをストリーミングで再生する構成にしました。

音声の置き場所の違い
音声ファイルの置き場所の違い

デモ

PIC で SD カードから音声ファイルを再生する

ここから PIC を使って SD カードに入ってる音声ファイルを再生する方法を説明します。これを応用すれば、SD カードを使った iPod shuffle のようなものなどを作ることが出来ます。

PIC と SD カードの接続

今回使用する PIC は前回と同じく PIC16F1705 です。この PIC には SPI が内蔵されているので、これを使って SD カードとやりとりをします。SPI とはプロトコルのようなもので、SPI の仕様に準拠して実装されたデバイス同士で通信をすることができます。SD カードにも通信方式の一つとして SPI が実装されており、SPI 経由でデータの読み書きが出来るようになっています。SPI を使う場合、SD カードに8本ある端子のうちの6本を使います。

SDカードの端子(SPIモード時)
micro SD カードの端子(SPIモード使用時)。SD をデバイスに繋いだ時に電源に繋がる GND と VCC が先に接続されるように他の端子より前に飛び出ている

これらの端子を以下のように PIC または電源に接続します。

SD カード PIC16F1705 16F1705 ピンアサイン
DO SDI (RC1)
GND VSS (電源)
CLK SCK (RC0)※
VCC VDD (電源)
DI SDO (要指定)※
CS SS (RC3)

※ が付いている項目は、以下のようにして割り当て先を任意のピンに変更することが出来ます。SDO はデフォルトではどのピンにもアサインされていないので、必ず指定する必要があります。

// SCK を RC2 ピンに割り当て
RC2PPS = 0b00010000;
// SDO を RC2 ピンに割り当て(SDO は必須)
RC2PPS = 0b00010010;

SPI の初期化

SPI ではデータのやり取りを4本のピン(SDI、SDO、SCK、SS)を介して行います。これらのピンを使い、以下のようにして PIC と SD カード間で通信します。

SPI通信
SDカードのSPI
SPI のコマンド(今回使用したもの)
命令 コマンド(8bit) 引数(32bit) CRC(8bit)
CMD0 カードリセット 0x40 0 0x95
CMD8 SDのバージョン取得 0x48 0x1aa 0x87
CMD55 アプリケーション特化コマンド 0x77 0 なし
CMD41 初期化 0x69 0x40000000 なし
CMD16 ブロックサイズ指定 0x50 512 なし
CMD17 シングルブロック読み出し 0x51 ブロック番号 なし

本来なら上図のような信号を各ピンから頑張って出力するのですが、SPI 機能がある PIC では専用の変数を使うことで簡単にデータを出し入れすることが出来ます。PIC から SD にコマンドを送るには以下のようにします。

// 8bit データを送受信
char get(char dt) {
    // 8ビットのデータを送信
    SSP1BUF = dt;
    // 送信中
    while (SSP1IF == 0);
    SSP1IF = 0;
    // 受信データ
    return SSP1BUF;
}
// コマンド(8bit)と引数(32bit)を送信
void send_command(char cmd, unsigned long arg) {
    get(cmd);
    get(arg >> 24);
    get(arg >> 16);
    get(arg >> 8);
    get(arg);
    // 0が返ってくるまで待つ
    while (get(0xff) != 0);
}
// ブロックサイズ指定(CMD16)に 512 を送信
void main() {
    send_command(0x50, 512);
}

サンプルのようにして、上記表のコマンドを以下のように順番に送信します。

  1. CMD0 を送信
  2. CMD8 を送信
  3. CMD41 が 0 を返すまで、CMD55、CMD41 の送信を繰り返す
  4. CMD16 を送信

これで SD カードからデータを読み書きできるようになります。データを読み込む場合は CMD17 に引数としてブロック番号を指定します。ブロックサイズは CMD16 で 512 B に指定しますので、例えば SD カードの先頭から 1024 B 〜 1535 B のデータを読み込みたい場合は、CMD17 の引数に 2 を指定します。

void open() {
    // CMD17 を送信(2ブロック目からデータを引き出す)
    send_command(0x51, 2);
    // データが返ってくるまで待つ
    while (1) {
        if (get(0xff) == 0xfe) {
            break;
        }
        __delay_us(1);
    }
    get(0xff); // 1024 バイト目のデータ
    get(0xff); // 1025 バイト目のデータ
    get(0xff); // 1026 バイト目のデータ
      :
}

FAT16 ファイルシステムの読み込み

ここから SD カードのファイルをオープンして行きます。今までの説明で CMD17 を使って SD カード内の任意の 512 B のデータを読み込めるようになりました。では、「00.wav」 のような特定のファイルは何ブロック目にあるでしょうか。ここで登場するのがファイルシステムです。
ファイルシステムとは、ディレクトリのツリー構造やファイルのメタ情報(ファイル名、容量、最終更新日時、そのファイルが何ブロック目にあるか、など)を管理する機能です。ファイルシステムの仕様に基いて SD カードを探索することで、任意のファイルにたどり着くことが出来ます。今回は FAT16 ファイルシステムからデータを読み込みます。

FAT16 の構造

FAT16の構造
FAT 16の構造

FAT16 のストレージは上図のような構造を取ります。ユーザーが保存したファイルやディレクトリの他に、各データエントリのサイズや個数などのメタ情報が BPB や FAT に記録されています。

FAT16 では 512 B 単位でデータをやり取りするのが一般的で、このデータのことをセクタと呼びます(上図の青色の枠)。SPI の CMD16(ブロックサイズ指定)で 512 を指定してあるので、CMD17(シングルブロック読み出し)に引数 n で読み込んだデータは、n 番目のセクタのデータ、ということになります。また、ユーザーデータにはクラスタというセクタのまとまりがあります(上図の緑色の枠)。ファイルの中身を読み出す時は、まずクラスタ番号を特定し、そこからセクタ番号を特定します。

MBR(マスターブートレコード)

CMD17(シングルブロック読み出し)の引数に 0、つまりストレージ内の最も先頭にあるセクタにあるのが MBR です。 ここにはストレージのパーティション情報が入っています。

MBR のデータ構造
バイト数 内容
446 ブートストラップローダ
16 x 4 パーティション(x4)
1 ブート可能デバイスかどうか
3 パーティションの物理的な先頭セクタ
1 パーティションの種類
3 パーティションの物理的な末尾セクタ
4 パーティションの論理的な先頭セクタ(offsetBPB)
4 全セクタ数
2 マジックナンバー
MBR
MBR

先頭にブートストラップローダがありますが、今回は読み飛ばします。その次にパーティション情報が4つ分入っています。ということは FAT16 で切れるパーティション数は最大で4つのようです。最後にマジックナンバーとして 0x55, 0xAA があり、これが MBR の全体像となります。各パーティションには、そのパーティションが確保しているセクタの範囲が示されています。このうち、パーティションの論理的な先頭セクタ(offsetBPB)(上図例では 0x2000)から、BPB を読みだすことが出来ます。

BPB(BIOS パラメータ・ブロック)

CMD17 にoffsetBPBの値を渡すと、BPB が得られます。BPB には、FAT までのセクタ数や RDE 内のエントリ数と言ったメタ情報が含まれています。

BPB のデータ構造(抜粋)
バイト範囲 内容 例の値
13 クラスタあたりのセクタ数(sectorsPerCluster) 0x40
14〜15 BPB が占めるセクタ数(sectorsPerBPB) 1
16 FATの数(FATs) 2
17〜18 RDE 内のエントリ数(rootEntries) 0x200
22〜23 FAT あたりのセクタ数(sectorsPerFAT) 0x100
BPB
BPB

BPB から FATRDEユーザーデータ の先頭セクタを以下のように求めることが出来ます。

FATの先頭セクタ(offsetFAT) = {offsetBPB} + {sectorsPerBPB}
RDEの先頭セクタ(offsetRDE)= {offsetFAT} + {sectorsPerFAT} * {FATs}
ユーザーデータの先頭セクタ(offsetDATA)= {offsetRDE} + {rootEntries} * 32 / 512

RDE(ルートディレクトリ・エントリ)

CMD17 にoffsetRDE の値を渡すと、RDE が得られます。RDE はルートディレクトリを表しています。つまり、SD カードを PC に挿した時に表示される一番最初のファイルリストと同一です。

RDE のデータ構造(抜粋)
バイト範囲 内容
32 x 16 ファイル/ディレクトリ エントリ
0〜7 ファイル/ディレクトリ名
26〜27 先頭クラスタ番号
28〜31 ファイル容量(ディレクトリの場合は 0
RDE
RDE

FAT16 ではファイルやディレクトリを削除した時、物理的にはデータは削除さません。代わりにファイル/ディレクトリ名の先頭バイトが0xE5となり、このことがファイル削除を表します。

ファイルの内容を読み込むには、先頭クラスタ番号FAT を参照し、ユーザーデータ上のセクタを特定します。先頭クラスタ番号は、例えば上図の「00.wav」では 0xA9 になります。

FAT(ファイルアロケーションテーブル)

FAT16 上で

  1. ファイルを2つ保存する
  2. 先に保存した方を削除する
  3. 別のファイルを保存する

という操作を行うと、ユーザーデータ領域は以下のようになります。

fat-1 fat-2
①ファイルA、Bを保存 ②ファイルAを削除(論理削除なので物理的には残っている)
fat-3 fat-4
③ファイルCを保存。左図のようにファイルBの後ろに追記… とはならず、実際には右図のように散らばる

FAT16 ではファイルを保存する際、ファイル削除によって空いたスペースの中にぶつ切りにデータを格納します。そのため、RDE の各エントリにある先頭クラスタ番号だけでは、このようにぶつ切りになってしまったデータは読み込むことが出来ません。そこで FAT の出番です。FAT は、散らばったデータがどのように配置されているかを、連結リストのような形式で保存しています。

下図に RDE から FAT を読み、ユーザーデータを読みだす方法を示します。

FAT
RDE -> FAT -> ユーザーデータの読み出し

まず RDE のファイルリストから読みたいデータを選びます(今回は「00.wav」とします)。「00.wav」の先頭クラスタ番号0xA9 です。この値は FAT 上の番地を指しています。FAT はクラスタ番号を 2 B で羅列したものなので、0xA9(169) なら offsetFAT + 0 セクタ内の 169 * 2 〜 169 * 2 + 1 番地にある 2 B を読み出します。

読み込むセクタ = offsetFAT + {先頭クラスタ番号} / 256
読み込む番地 = offsetFAT + ({先頭クラスタ番号} % 256) * 2

FAT の該当セクタの該当番地には 0xAA があります。この値は、次のクラスタ番号を表しています。先ほどと同じように読んでいくと、0xAB、0xFFFF が得られます。クラスタ番号が 0x1 以下 または 0xFFF7 以上 なら、それ以上クラスタが続かないこと(ファイルの終端)を表しています。つまり、00.wav のクラスタ番号はファイルの先頭から順に 0xA9,0xAA,0xAB の3つだということが分かりました。BPB の説明で クラスタあたりのセクタ数0x40(64) でしたので、このファイルの容量は (512 * 64 * 2 〜 512 * 64 * 3) B、つまり 65536 〜 98304 B ということが推定できます(実際に 00.wav は 90010 B)。

最後にクラスタ番号を使い、ユーザーデータから実際のファイル内容を読み込みます。クラスタ番号から、そのクラスタ内の先頭セクタを求めるには以下のようにします。

クラスタ{n}の先頭セクタ = offsetDATA + (n - 2) * sectorsPerCluster

クラスタの値から -2 しているのは、0x00x1 のクラスタは予約されていて、ユーザーデータには 0x2 番クラスタから格納されているからです。クラスタ内のセクタは順番につながっているので、以下の順にセクタを開くと、ファイル全体を読み込むことが出来ます。

offsetDATA + (0xA9 - 2) * sectorsPerCluster
offsetDATA + (0xA9 - 2) * sectorsPerCluster + 1
offsetDATA + (0xA9 - 2) * sectorsPerCluster + 2
  :
offsetDATA + (0xA9 - 2) * sectorsPerCluster + sectorsPerCluster - 1
offsetDATA + (0xAA - 2) * sectorsPerCluster
  :
offsetDATA + (0xAA - 2) * sectorsPerCluster + sectorsPerCluster - 1
offsetDATA + (0xAB - 2) * sectorsPerCluster
  :
offsetDATA + (0xAB - 2) * sectorsPerCluster + sectorsPerCluster - 1
ファイルダンプ
読み込まれた 00.wav(0xA9クラスタの先頭セクタ)
サブディレクトリ

00.wav を開くのと同じようにサブディレクトリを開くと、以下のようなデータが得られます。サブディレクトリはデータ構造は RDE と同じです。RDE と違い、サブディレクトリは最初に.(カレントディレクトリ)..(親ディレクトリ)というディレクトリが入っています。

サブディレクトリ
サブディレクトリ

SD カードから音声を再生する

SD から音声ファイルが読み込めるようになったので、実際にスピーカーから鳴らしてみます。スピーカーから音を鳴らすやり方は昨日の記事で説明しているので割愛します。ただし、昨日の記事では配列に入れてある音声データをただなぞるだけでしたが、今回は SD カードから読み込むので勝手が違います。

PIC16F1705 は SRAM が 1024 B しかないので、SD カードの音声ファイルをすべて読み込んでから再生開始、とは行かず、読み込みながら再生しなければなりません。今回は 128 B の配列をバッファとして、SD からのデータ読み込みが再生ヘッダに追いつかれないようにしつつ、かつ読み込みすぎて再生ヘッダが周回遅れにならないように注意して実装しました。僕の実装ではセクタの読み出しがボトルネックになり、サンプルレートは 22050 Hz が限界でした。

unsigned char soundBuffer[128];
unsigned long gsrc = 0;
unsigned long gdst = 0;
unsigned long fileSize = 90010;
void play() {
    int i, j, k;
    // サウンドの割り込みタイマ開始
    TMR0IE = 1;
    // クラスタのループ
    do {
        // クラスタ内セクタのループ
        for (k = 0; k < numOfSectorsPerCluster; k++) {
            // セクタを開く
            open_sector_with_cluster(k, cluster);
            // 512 B のループ(128 B の配列を 4回まわす)
            for (j = 0; j < 4; j++) {
                for (i = 0; i < 128; i++) {
                    // ここで歩調を合わせる(重要)
                    while (gsrc >= gdst);
                    soundBuffer[i] = get(0xff);
                    gsrc++;
                }
            }
            close();
        }
    // 次のクラスタを特定
    } while ((cluster = next_cluster(cluster)) != 0);
}
void interrupt InterTimer(void) {
    if (TMR0IF == 1) {
        TMR0IF = 0;
        TMR0 = 83;
        int v = 0;
        if (gdst < fileSize) {
            // タイマー側はバッファを舐めるだけ
            v = soundBuffer[gdst & 0x7f];
        }
        gdst++;
        DAC1CON1 = v;
    }
}

SD カードの読み出しとバッファ配列、再生ヘッダのタイミングは以下のようになっています(セクタサイズとバッファを 8 B としています)。

ねんどろいど伊401に時報を実装する

ブレッドボード上でこのように実装した回路をねんどろいどの頭部パーツ(各辺2.5cm前後)に入るように実装した結果、

ブレッドボード上の実装

こうなりました。

ユニバーサル基板での実装

ねんどろいどに組み込むとこのようになります。ガジェットが大きくなってしまったので、表情パーツをはめた時に少し隙間が出来てしまいました(中央)。が、ここから音が漏れるのでちょうど良いです。

i1 i2 i3

ソースコードを Github に上げておきましたので、興味のある提督はぜひチャレンジしてみてください。
Github: manse/i401

まとめ

僕の中で PIC は LED をチカチカさせる程度の代物でしたが、今回の開発で実用的な製品も作れることが分かりました。ソフトウェア開発に飽きてきた方や、スマートフォン開発をしていて「スマホのセンサーだけでは足りない!」と感じている方には特におすすめです。PIC を使ったハードウェア開発は実は C 言語を使ったソフトウェア開発の部分も大きいので、初めての方でも決して難しいことではないです。ぜひ一度秋葉原かオンラインショップに足を運んでみましょう。

References