🙏

組み込みで般若心経を扱う際の効率的なメモリ配置方法の検討

2021/05/17に公開

ATmega32u4の貧弱なメモリから般若心経を音節ごと(厳密には拍ごと)に取り出すにあたり、最も効率良くメモリに格納する方法を考えてみました。
あんまり使う人いないと思うけど作っちゃったしせっかくなのでメモ。

https://twitter.com/ukokq/status/1392305811724984321?s=20

前提条件

  • 汎用性の高いUSB HIDとしての出力をさせたいため、取り出す音節の表現は漢字ではなく、ひらがなでもなく、英字(ローマ字入力)であることが必要です。音節ごとに固定長にはなりません。
ダメ
仏説摩訶般若波羅蜜多心経...
ダメ
ぶっ せつ ま か はん にゃ は ら ...
よい
bultu setsu ma ka hann nya ha ...
  • SRAMではなくPROGMEMを使ってフラッシュメモリに保存したいので、オブジェクトではなくプリミティブな型の配列を使いたいです。
ダメ
String sutra[100] = {"bultu", "setsu", "ma", "ka", ... };
よい…?
char sutra[100][6] = {"bultu", "setsu", "ma", "ka", ... };

般若心経を音節入力変換する

ひらがなで取得する

http://www.matsumasa.com/report/heartsutra/2.html

ローマ字表記に変換する

https://tools.m-bsys.com/original_tooles/romaji.php

さらに音節ごとに半角スペースで切り分け、拗音・撥音・長音を単音節で入力できるように調整します。(例:bussetsu → bultu setsu

https://timetoenjoy.info/archives/112

ローマ字表記変換後の冒頭
bulsu setsu ma ka hann nya ha ra miltu ta shin gyou
kan ji zai bo satsu
gyou jinn hann nya ha ra miltu ta ji
shou kenn go unn kai kuu
do iltu sai ku yaku
sha ri shi
...

https://www.luft.co.jp/cgi/str_counter.php

ここまでで、改行とスペースを除去して811バイトでした。
ATmega32u4のSRAMは2560bytes。
char配列を使うとSRAMでもFlashでも入るけれど、Stringを使ってしまうとオブジェクト構造含めてSRAMに入れることになるので、かなり厳しいです。
音節区切りのスペースを含めると1030バイトほどになります。

共通音節の配列を作成する

上図のような同一の音節部分をchar配列にしてしまい、そこへのポインタの配列にしてしまえばメモリ節約することができそうです。

1つの音節を構成する英字は最大5文字なので、まず同じ文字数の音節ごとの配列を1〜5文字の5種類作成します。終端コードが含まれるため、列長は文字数+1が必要です。

char syllables_1[4][2]  = { "a", "i", "u", "e" };
char syllables_2[26][3] = { "ka", "ku", "ke", "ko", "ta", "ni", "ne", "ha", "fu", "ma", "mi", "mu", "ge", "go", "ze", "ji", "ju", "jo", "do", "bi", "bu", "bo", "ra", "ri" };
char syllables_3[26][4] = { "kai", "kuu", "kou", "sai", "shi", "sou", "chi", "tei", "tou", "nai", "nou", "hou", "gya", "sha", "shu", "sho", "zai", "zou", "jou", "dai", "dou", "nya", "nyo", "rou", "unn", "onn" };
char syllables_4[19][5] = { "iltu", "kann", "kenn", "sann", "soku", "sowa", "tenn", "toku", "noku", "hara", "hann", "yaku", "genn", "gyou", "shuu", "shou", "jinn", "chuu", "myou" };
char syllables_5[14][6] = { "satsu", "saltu", "shiki", "shinn", "setsu", "toltu", "miltu", "metsu", "watsu", "zeltu", "jitsu", "butsu", "bultu", "myaku" };

※ラ行のみ忘れていてあとで気づいて追加したので五十音順ではないです。

さらに以下の処理を行います。

  • shi, chi, tsusi, ti, tu にしてより文字数の少ない配列に移動。(ローマ字で入力する上では同じなので)
  • ~nnである音節は全て4文字配列に属するため、それらを全て~nにして3文字の「〜ん」専用配列を作る。その配列のみ入力後にnを補完することとする。
    • と思ったらunnonnだけ例外だったため通常の3文字配列に入れたままにしておく。
char syllables_1[4][2]  = { "a", "i", "u", "e" };
char syllables_2[26][3] = { "ka", "ku", "ke", "ko", "si", "ta", "ti", "ni", "ne", "ha", "fu", "ma", "mi", "mu", "ge", "go", "ze", "ji", "ju", "jo", "do", "bi", "bu", "bo", "ra", "ri" };
char syllables_3[24][4] = { "kai", "kuu", "kou", "sai", "sou", "tei", "tou", "nai", "nou", "hou", "gya", "sha", "shu", "sho", "zai", "zou", "jou", "dai", "dou", "nya", "nyo", "rou", "unn", "onn" };
char syllables_N[8][4]  = { "kan", "ken", "san", "sin", "ten", "han", "gen", "jin" };
char syllables_4[19][5] = { "iltu", "satu", "siki", "setu", "soku", "sowa", "toku", "noku", "hara", "metu", "yaku", "watu", "gyou", "shuu", "shou", "jitu", "chuu", "butu", "myou" };
char syllables_5[6][6]  = { "saltu", "toltu", "miltu", "zeltu", "bultu", "myaku" };

これらの変換にあわせて般若心経本体の音節表記も書き換えます。

簡略ローマ字表記変換後の冒頭
bultu setu ma ka han nya ha ra miltu ta sin gyou
kan ji zai bo satu
gyou jin han nya ha ra miltu ta ji
shou ken go un kai kuu
do iltu sai ku yaku
sha ri si
...

般若心経をバイト列に変換(符号化)する

バイト列変換にあたっては、1バイト中に「どの配列の」「何番目の音なのか」の2種類の情報を入れる必要があります。ここでは音節配列は6種類あるので、

  • 上位3ビットを配列の指定に(0〜7)
  • 下位5ビットを配列内のインデックスに(0〜31)

のように指定することができます。
このルールに基づくと以下のように1音節をバイト化できます。

byte a     = B00000000; // (0x00) "a" は1文字配列のインデックス0
byte gya   = B01001100; // (0x4C) "gya" は3文字配列のインデックス12
byte zeltu = B10100011; // (0xA3) "zeltu" は5文字配列のインデックス3

参考:DEC/HEX/BIN table

各配列の数値範囲は以下の通りとなります。

char syllables_1[4][2];  // B00000000 ~ B00000011 (0x00 ~ 0x03)
char syllables_2[26][3]; // B00100000 ~ B00111001 (0x20 ~ 0x39)
char syllables_3[24][4]; // B01000000 ~ B01010111 (0x40 ~ 0x57)
char syllables_N[8][4];  // B01100000 ~ B01100111 (0x60 ~ 0x67)
char syllables_4[19][5]; // B10000000 ~ B10010010 (0x80 ~ 0x92)
char syllables_5[6][6];  // B10100000 ~ B10100110 (0xA0 ~ 0xA5)

これらのバイト列で書き換えると、般若心経本文は以下のようになります。
全く読めない。。。

バイト列変換後の冒頭
// ぶっせつまかはんにゃはらみたしんぎょう
0xA4, 0x83, 0x2B, 0x20, 0x65, 0x53, 0x29, 0x38, 0xA2, 0x25, 0x63, 0x8C,
// かんじざいぼさつ
0x60, 0x31, 0x4E, 0x37, 0x81,
// ぎょうじんはんにゃはらみったじ
0x8C, 0x67, 0x65, 0x53, 0x29, 0x38, 0xA2, 0x25, 0x31,
// しょうけんごうんかいくう
0x8E, 0x61, 0x2F, 0x56, 0x40, 0x41,
// どいっさいくやく
0x34, 0x80, 0x43, 0x21, 0x8A,
// しゃりし
0x4B, 0x39, 0x24,
...

このあと、各行の末尾に改行を示す0xF1を付与しておきました。(改行コードではなく、改行だということがわかるような独自のバイトであればなんでもよい)
これは文字入力後に改行コードを送るタイミングを設けることで、一定数入力したあとにIMEを確定させる機能を持たせたいためです。

データ量の計算と比較

音節配列
char syllables_1[4][2];  // 8バイト
char syllables_2[26][3]; // 78バイト
char syllables_3[24][4]; // 96バイト
char syllables_N[8][4];  // 32バイト
char syllables_4[19][5]; // 95バイト
char syllables_5[6][6];  // 36バイト

// 合計 345バイト

般若心経本文:330バイト(改行込)・276バイト(改行無)

∴ Stringクラス(811バイト) → 音節配列+バイト列(621バイト)

改行無しで比較すると、音節区切りコントロールが出来るようになったにも関わらず200バイトほど小さくできました。
……意外と圧縮できませんでした(涙)

ただし、般若心経本体の「276バイト」は、玄奘訳本の文字数[1]と同じになり、音声学上の1音節よりもわずかに情報量が多くなる般若心経の1拍[2]をほぼ同じデータ量にできているということは、ある程度は符号化圧縮が成立しているとも言えます。

またPROGMEMで記録できるためSRAMを節約することにはつながっており、SRAM節約をより簡易的にできるchar型の二次元配列(文字列テーブル)と比較すると半分以下の容量には抑えられていることがわかります。

文字列テーブルだと
char sutra[276][6] = {"bultu", "setsu", "ma", "ka", ... };
// 1656バイト

この符号化された般若心経を扱うにはもう一工夫必要です。
このデータそのままを出力するわけにはいかず、管理するクラスを作ってそこからコントロールする必要があります。

1音節ずつ取り出せるクラスを作る

音節の配列は構造体にするか三次元配列にしたほうがスマートかもしれません。

Chanting.h
#include "HeartSutra.h"
#define ENTER             "\n"

class Chanting {
  
  private:
    unsigned int cursor = 0; // 読経位置インデックス
    bool finished = false; // 末端まで達したか
    String output = " "; // 現在の出力(実際の音節文字列)
    // 以下 文字数ごとの音節配列
    char syl_1[4][2]  = { "a", "i", "u", "e" };
    char syl_2[26][3] = { "ka", "ku", "ke", "ko", "si", "ta", "ti", "ni", "ne", "ha", "fu", "ma", "mi", "mu", "ge", "go", "ze", "ji", "ju", "jo", "do", "bi", "bu", "bo", "ra", "ri" };
    char syl_3[24][4] = { "kai", "kuu", "kou", "sai", "sou", "tei", "tou", "nai", "nou", "hou", "gya", "sha", "shu", "sho", "zai", "zou", "jou", "dai", "dou", "nya", "nyo", "rou", "unn", "onn" };
    char syl_N[8][4]  = { "kan", "ken", "san", "sin", "ten", "han", "gen", "jin" };
    char syl_4[19][5] = { "iltu", "satu", "siki", "setu", "soku", "sowa", "toku", "noku", "hara", "metu", "yaku", "watu", "gyou", "shuu", "shou", "jitu", "chuu", "butu", "myou" };
    char syl_5[6][6]  = { "saltu", "toltu", "miltu", "zeltu", "bultu", "myaku" };
    
  public:
    Chanting() {}
    
    String get() {
      // 終了している場合は改行のみを返す
      if (finished || cursor >= sizeof(HeartSutra)) return String(ENTER);
      
      // 現在のコードと1つ先のコードを取得
      byte code = pgm_read_byte(HeartSutra + cursor);
      byte next = pgm_read_byte(HeartSutra + cursor + 1);
      cursor++; // 進めておく
      
      // 上位3ビットで音節配列選択・下位5ビットでインデックス選択
      byte sylClass = code >> 5;
      byte sylIndex = code & 0x1F;
      switch (sylClass) {
        case 0:
          output = String(syl_1[sylIndex]);
          break;
        case 1:
          output = String(syl_2[sylIndex]);
          break;
        case 2:
          output = String(syl_3[sylIndex]);
          break;
        case 3:
          output = String(syl_N[sylIndex]) + String("n"); // ここだけnを付加する
          break;
        case 4:
          output = String(syl_4[sylIndex]);
          break;
        case 5:
          output = String(syl_5[sylIndex]);
          break;
        default:
          output = String(""); // 対応する音節配列がない
          break;
      }

      // 先読みしたコードが特殊操作(0xF0 ~ 0xFF)なとき
      if (next >= 0xF0) {
        if (next == 0xF0) finished = true;
        if (next & 0x01) output += String(ENTER);
        if (next & 0x02) output += String(" "); // 0xF3はSPACE+ENTER
        cursor++; // もう1つ次に飛ばしておく(特殊コードは連続しない前提)
      }
      
      return output;
    }
    
    // インデックスを最初に戻す
    void reset() {
      cursor = 0;
      finished = false;
    }
};

このあたりの変換クラスも含めればあまりメモリ使用量は変わらないかもしれませんが、他のお経を入れるなどしてデータをさらに増やす場合には、急激にメモリが少なくなるということは起こりにくくなるかもしれません。

般若心経本体

長いのでこちら
HeartSutra.h
/*
 * [特殊コード]
 *   0xF0: 終了(末尾)
 *   0xF1: 改行
 *   0xF2: 半角スペース
 *   0xF3: 半角スペース+改行
 * IMEは自動変換(ライブ変換)設定を推奨しますが、
 * うまく変換できない際には0xF1~0xF3を適宜スキマに入れてください。
 */
const byte HeartSutra[] PROGMEM = {
  // ぶっせつまかはんにゃはらみったしんぎょう
  0xA4, 0x83, 0x2B, 0x20, 0x65, 0x53, 0x29, 0x38, 0xA2, 0x25, 0x63, 0x8C, 0xF1,
  // かんじざいぼさつ
  0x60, 0x31, 0x4E, 0x37, 0x81, 0xF1,
  // ぎょうじんはんにゃはらみたじ
  0x8C, 0x67, 0x65, 0x53, 0x29, 0x38, 0xA2, 0x25, 0x31, 0xF1,
  // しょうけんごうんかいくう
  0x8E, 0x61, 0x2F, 0x56, 0x40, 0x41, 0xF1,
  // どいっさいくやく
  0x34, 0x80, 0x43, 0x21, 0x8A, 0xF1,
  // しゃりし
  0x4B, 0x39, 0x24, 0xF1,
  // しきふいくう
  0x82, 0x2A, 0x01, 0x41, 0xF1,
  // くうふいしき
  0x41, 0x2A, 0x01, 0x82, 0xF1,
  // しきそくぜくう
  0x82, 0x84, 0x30, 0x41, 0xF1,
  // くうそくぜしき
  0x41, 0x84, 0x30, 0x82, 0xF1,
  // じゅそうぎょうしきやくぶにょぜ
  0x32, 0x44, 0x8C, 0x82, 0x8A, 0x36, 0x54, 0x30, 0xF1,
  // しゃりし
  0x4B, 0x39, 0x24, 0xF1,
  // ぜしょほうくうそう
  0x30, 0x4D, 0x49, 0x21, 0x44, 0xF1,
  // ふしょうふめつ
  0x2A, 0x8E, 0x2A, 0x89, 0xF1,
  // ふくふじょう
  0x2A, 0x21, 0x2A, 0x50, 0xF1,
  // ふぞうふげん
  0x2A, 0x4F, 0x2A, 0x66, 0xF1,
  // ぜこくうちゅう
  0x30, 0x23, 0x41, 0x90, 0xF1,
  // むしき
  0x2D, 0x82, 0xF1,
  // むじゅそうぎょうしき
  0x2D, 0x32, 0x44, 0x8C, 0x82, 0xF1,
  // むげんにびぜっしんに
  0x2D, 0x66, 0x27, 0x35, 0xA3, 0x63, 0x27, 0xF1,
  // むしきしょうこうみそくほう
  0x2D, 0x82, 0x8E, 0x42, 0x2C, 0x84, 0x49, 0xF1,
  // むげんかい
  0x2D, 0x66, 0x40, 0xF1,
  // ないしむいしきかい
  0x47, 0x24, 0x2D, 0x01, 0x82, 0x40, 0xF1,
  // むむみょう
  0x2D, 0x2D, 0x92, 0xF1,
  // やくむむみょうじん
  0x8A, 0x2D, 0x2D, 0x92, 0x67, 0xF1,
  // ないしむろうし
  0x47, 0x24, 0x2D, 0x55, 0x24, 0xF1,
  // やくむろうしじん
  0x8A, 0x2D, 0x55, 0x24, 0x67, 0xF1,
  // むくしゅうめつどう
  0x2D, 0x21, 0x8D, 0x89, 0x52, 0xF1,
  // むちやくむとく
  0x2D, 0x26, 0x8A, 0x2D, 0x86, 0xF1,
  // いむしょとっこ
  0x01, 0x2D, 0x4D, 0xA1, 0x23, 0xF1,
  // ぼたいさった
  0x37, 0x51, 0xA0, 0x25, 0xF1,
  // えはんにゃはらみったこ
  0x03, 0x65, 0x53, 0x29, 0x38, 0xA2, 0x25, 0x23, 0xF1,
  // しんむけげ
  0x63, 0x2D, 0x22, 0x2E, 0xF1,
  // むけげこ
  0x2D, 0x22, 0x2E, 0x23, 0xF1,
  // むうくふ
  0x2D, 0x02, 0x21, 0x2A, 0xF1,
  // おんりいっさいてんどうむそう
  0x57, 0x39, 0x80, 0x43, 0x64, 0x52, 0x2D, 0x44, 0xF1,
  // くぎょうねはん
  0x21, 0x8C, 0x28, 0x65, 0xF1,
  // さんぜしょぶつ
  0x62, 0x30, 0x4D, 0x91, 0xF1,
  // えはんにゃはらみたこ
  0x03, 0x65, 0x53, 0x29, 0x38, 0xA2, 0x25, 0x23, 0xF1,
  // とくあのくたらさんみゃくさんぼだい
  0x86, 0x00, 0x87, 0x25, 0x38, 0x62, 0xA5, 0x62, 0x37, 0x51, 0xF1,
  // こちはんにゃはらみった
  0x23, 0x26, 0x65, 0x53, 0x29, 0x38, 0xA2, 0x25, 0xF1,
  // ぜだいじんしゅ
  0x30, 0x51, 0x67, 0x4C, 0xF1,
  // ぜだいみょうしゅ
  0x30, 0x51, 0x92, 0x4C, 0xF1,
  // ぜむじょうしゅ
  0x30, 0x2D, 0x50, 0x4C, 0xF1,
  // ぜむとうどうしゅ
  0x30, 0x2D, 0x46, 0x52, 0x4C, 0xF1,
  // のうじょいっさいく
  0x48, 0x33, 0x80, 0x43, 0x21, 0xF1,
  // しんじつふこ
  0x63, 0x8F, 0x2A, 0x23, 0xF1,
  // こせつはんにゃはらみったしゅ
  0x23, 0x83, 0x65, 0x53, 0x29, 0x38, 0xA2, 0x25, 0x4C, 0xF1,
  // そくせつしゅわつ
  0x84, 0x83, 0x4C, 0x8B, 0xF1,
  // ぎゃていぎゃてい
  0x4A, 0x45, 0x4A, 0x45, 0xF1,
  // はらぎゃてい
  0x29, 0x38, 0x4A, 0x45, 0xF1,
  // はらそうぎゃてい
  0x88, 0x44, 0x4A, 0x45, 0xF1,
  // ぼじそわか
  0x37, 0x31, 0x85, 0x20, 0xF1,
  // はんにゃしんぎょう
  0x65, 0x53, 0x63, 0x8C, 0xF1,
  // 終了コード
  0xF0
};

使ってみる

String syl = chanting.get();
Keyboard.print(syl);

実際に使う場合はこのようにget()を呼ぶと音節ごとに出力され、カーソルも自動で次に進みます。

より圧縮するには

  • 「般若波羅蜜多」や「羯諦」のような繰り返しフレーズ単位をまとめるとよりサイズを小さくできそう。
  • エントロピーを考えると、音節あたりに必要な文字数が少ないものは1バイトもいらないかも。
脚注
  1. https://shakyoya.ocnk.net/page/96
    ただしこの記事で扱った現行流通本によるものとは文字数が異なる上、音節数と文字数は必ずしも一致しないため偶然である。 ↩︎

  2. 例えば「即説呪曰」は、音声学的には7音節だが実際の読経では8分音符のように4拍で唱えきってしまう。 ↩︎

Discussion