スマートグラス G2 に音の形を映す ── Even Hub アプリ開発の備忘録

EVEN G2 のマイクで拾った音を、波形・スペクトラム・そして音のアトラクタとして視界に映すアプリを作った。Even Hub の構造、実機で測ったハードの癖、つまずきと自分の思い込みの訂正まで、次の自分のために記録しておく。


スマートグラスの EVEN G2 が手元に来てから、ずっとやりたかったことがある。グラスのマイクが拾った音を、その場で「形」にして視界に映すこと。波形、スペクトラム、それから ── 音のアトラクタ。それが、動くところまで形になった。Even Hub にも申請を出して、いまはレビューを待っているところだよ。

この記事は、その過程の記録だ。同時に、次に同じことをやる自分(と、たぶん誰か)のための備忘録でもある。Even Hub がどんな構造で、ハードにどんな癖があって、どこでつまずいて、どこで自分の思い込みを訂正したか。順番に並べておくよ。

Even Hub の構造 ── 頭はスマホ、眼はグラス

最初に掴んでおくと楽なのは、処理の本体はスマホで動くということ。グラスは「表示と入力の端末」で、両者は BLE でつながっている。

だから作るものの実体は、スマホの WebView で動く Web アプリだ。WebView というのは、アプリの中に埋め込まれた“ブラウザの画面”のこと ── 見た目はスマホアプリでも、中身は普通の Web ページ(HTML/JS)で、Web 開発の道具がそのまま使える。だから今回も HTML/JS(TypeScript)で書く。そこに SDK(Software Development Kit = その機器向けの「開発キット」。必要な部品とお作法をまとめたライブラリのこと)として @evenrealities/even_hub_sdk を入れると、bridge というオブジェクトが、グラスへの描画と、グラスからのイベントを橋渡ししてくれる。

  • 画面は「コンテナ」の組み合わせ ── テキストコンテナと画像コンテナを置く。
  • 描画は bridge.updateImageRawData(...) やテキスト更新で、その都度グラスへ送る。
  • 入力は onEvenHubEvent で届く(タップ、ダブルタップ、IMU…)。
  • 動くのはフォアグラウンドのみ

完成したら Web アプリを .ehpk という1ファイルに固めて、Even Hub のポータルに申請する。そこで人手のレビューが入る ── これは少し安心する仕組みだった。出したものに、責任の所在ができるということだから。

整理すると、こういう分担だね。

スマホ:音を取る・FFT する・絵を描く(計算の全部) グラス:その絵を映す・タップを返す(入出力だけ)

実機で測った、ハードの癖

ここからが備忘録の本体だ。ドキュメントに無くて、実機で測って初めてわかったことを残しておく。

ディスプレイ:片眼 576×288、緑のモノクロ4ビット=16階調の緑。色は無い。渋い、けれど嫌いじゃない。(初出時に「4階調(gray4)」と書いたが、これは私の誤りだった。“gray4” は 4 段階ではなく 4ビット=16 段階のこと。後で公式ドキュメントを読んで訂正した。)

マイク:これが肝だった。仕様に明記が無かったので、届く生データのバイト数を実機で数えた。結果は ── 16 kHz / 16bit 符号付き / モノラル。100ms ごとに 3200 バイト=1600 サンプル届く。最初は 8bit かもと疑っていたけれど、int16 として読むと値が素直で、16bit で確定した。これが決まると、スペクトラムの周波数軸が実機で正しいと言える。憶測で軸を描かずに済んだのが、いちばん安心した瞬間だったよ。

画像の渡し方:ここが一番ハマった。画像コンテナに渡すデータが、生ピクセルでも、base64文字列でも、dataURL でもなく ── PNG ファイルのバイト列だった。PNG は画像ファイルの形式の一つ(写真でよく使う JPEG の仲間で、ロゴや線画みたいなパキッとした絵に向く、劣化のない圧縮形式だ)。要するに「画素をむき出しで並べたデータ」ではなく、PNG という決まった型に整えて圧縮した一塊のファイルを渡す必要があった。ホスト側はその PNG をデコード(展開)して、16 階調(4ビット)の緑に落として表示する。6つの候補(生グレー/1bpp/2bpp/dataURL/base64文字列/PNGバイト列)を1.2秒ごとに順に試す「形式プローブ」をアプリに仕込んで、実機で success が返るものを探した。通ったのは PNG バイト列だけ。

(補足・訂正:SDK のドキュメントを読み直すと、imageDatanumber[] / Uint8Array / base64 文字列まで受け付けると書いてある ── つまり「base64 は不可」と言い切るのは私の早とちりだった。正しくは、渡すデータの中身が PNG エンコードされていることが肝で、それを number[]Uint8Array のバイト列として渡すのが確実、ということ。PNG エンコードというのは、むき出しの画素の並びを、さっきの PNG の規則(ヘッダ+圧縮された画素)に“詰め直す”変換のこと ── ブラウザなら canvas.toDataURL('image/png') などがやってくれる。受け手はそれをデコード(逆の展開)して画にする。つまり渡す前に必ず一度 PNG に焼く、という一手間が要る。あとで公開アプリ EvenGo-Paris のコードを見たら、そこも canvas.toDataURL('image/png') をバイト列(number[])に変換して imageData に渡していて、まったく同じ作りだった。実機で測った結論と、他の人の実装が一致したのは気持ちよかったよ。)文書に無いなら、試して確かめるしかない。

速度はサイズで決まる:ここで、自分の思い込みを一つ訂正した。最初は「画像はどう頑張っても毎秒1コマが天井」だと思っていた。けれど公式・コミュニティの資料を読み直して測ると、それは間違いで ── 更新速度は画像のサイズ次第だった。288×144 でおよそ 1fps、30×30 まで小さくすると 9fps 近く出る。「小さくすれば、画像でも滑らかに動かせる」。天井だと思っていたものは、ただの大きさの問題だった。

思い込み:画像は 1fps が限界 → 文字で逃げるしかない 実測後 :fps はサイズ依存。小さい画像なら綺麗なまま動く

これは気持ちのいい訂正だった。誤魔化さずに測ると、世界はちゃんと広がる。

(ついでに)テキストは画像よりずっと速い。だから「文字で描くバー(█ のブロック文字)」のモードは、音楽に合わせてフルフル動く。あと、テキストが画面の高さを超えると右端にスクロールバーが出る ── これを最初「謎の縦線」と呼んで原因を探していた。行数を画面に収めたら消えた。地味だけど、いちばん備忘録向きの罠かもしれない。

作ったもの ── Spectrum Lab の6つの眼

音 → PCM → 自前の FFT(radix-2 + Hann 窓)→ 緑モノクロに描画 → グラスへ。この一本道に、6つの「見せ方」を載せた。

  • Music EQ:文字で描く高速バー。拍で跳ねる。いちばん速い。
  • Spectrum:周波数ごとの棒。高い棒が、いちばん大きい音。
  • Oscilloscope:生の波形そのもの。
  • Bars:ピークホールドの帽子つき、LED 風の帯。
  • Spectrogram:周波数×時間の滝。口笛が曲線に、声が母音の縞になる。
  • Attractor:いちばん作りたかったやつ。

アトラクタだけ、少し説明させて。これは 遅延埋め込み(Takens embedding) という方法で、音 s(t)s(t)

(s(t),  s(tτ))\big(\,s(t),\; s(t-\tau)\,\big)

の点列として平面に打つ。つまり「いまの音」と「少し過去の音」を組にして描く。純音なら ── 円を描く。倍音が混じると閉じた曲線になり、整数比でない音が混じると、もつれた曲線になる。一本の信号から、その音が動いている「状態空間の形」を取り出せる。理屈がきれいで、しかも動かすと本当にそう見える。これは、見ていて面白いんだ。

操作は、スマホ側がつまみ盤になっている(周波数上限、感度、残光、スクロール速度…)。調整した値はグラスに保存されるから、一度決めれば次からはリングのタップだけで切り替えられる。読むだけでなく、つまみを回して掴める道具にしたかった。

信号処理の、正直な一行

ひとつだけ、混同しがちなことを書いておく。スペアナで「見える範囲」と「細かさ」は、別物だ。

  • 見える範囲=ナイキスト周波数 fs/2f_s/2。マイクの fsf_s は 16kHz 固定で、こちらでは選べない。だから上限 8kHz は動かせない(表示でズームはできる)。
  • 細かさ=ビン幅 fs/Nf_s/N。これは FFT 長 NNいつでも変えられるNN を伸ばせば、隣り合う音を分離できる。

「もっと高い音まで見たい」と「もっと細かく見たい」は、効くつまみが違う。ここを混ぜないのが、正直なスペアナだと思う。

プログラム的なつまずき(自分用メモ)

最後に、コードで踏んだ地雷を残しておく。どれも「ローカルでは動くのに、なぜ?」の形をしていて、正体が分かるとスッとするやつだった。せっかくなので、なぜそうなるかまで書いておく。

1. ビルドだけが落ちる ── 開発サーバとビルドで、見ている人が違う

FFT や補助関数を別ファイル(.mjs)に切り出していた。開発サーバ(esbuild)はこれを素通りで動かしてくれるのに、本番ビルドの tsc --noEmit だけが赤くなる。エラーは TS7016 ──「この import には型の宣言ファイル(.d.ts)が無い、暗黙の any は許さない」というもの。原因は分業だ。開発サーバは“とりあえず変換して走らせる”だけで型を見ない/ビルドの型チェックは別の厳しい目で見る。二つは別の道具なので、「ローカルで動いた」は「ビルドが通る」を保証しない。tsconfigallowJs: true を足して、型の無い .mjs も受け入れて解決した。教訓は、通る/通らないは “誰がチェックしているか” で変わる、ということ。

2. 権限は、理由とセットでしか書けない

アプリが使う権限(マイク、など)を申請するとき、["microphone"] のような文字列の配列で書いたら弾かれた。正しくは { name, desc } のオブジェクトで、desc(なぜその権限が要るかの説明・1〜300字)が必須だった。レビューや install のときに人へ見せる「言い訳」を、機械が強制してくる作りになっている。黙ってマイクを取れない、というのは考えてみれば良い設計だ。

3. edition は “好きな数字” ではなかった

pack(アプリを1ファイル .ehpk に固める工程)で、edition という項目に別の値を入れたら通らなくなった。使った CLI では "202601" の一値だけが受理される。自分のアプリのバージョン番号のつもりで上げると、packing が落ちる。これは SDK/ポータル側の対応版に紐づいた固定値で、こちらが自由に決めるフィールドではない、というだけの話なのだけど、最初は勘違いして時間を溶かした。

4. タップが効かない ── ゼロは、消える(これが一番きれいな罠だった)

グラスのリングをタップしても、アプリが反応しないことがあった。原因を追うと、イベントは protobuf という形式で送られていて、これは通信量を節約するために 「初期値(ゼロ)と同じ値のフィールドは、送らずに省く」 という仕様を持っている(proto3 の既定動作)。ところが、タップ(CLICK)を表す番号が、よりによって 0。つまり eventType = 0 は「ゼロだから」と送信時に省かれて、受け取り側には undefined で届く。だから「eventType が CLICK(=0)と等しいか?」で判定すると、永遠に当たらない。

// ダメ:ゼロ値が送信時に省かれて、undefined になる
if (e.eventType === CLICK) nextMode()
// 良い:種類ではなく「入力が来た」という事実で判定する
if (isInput(e)) nextMode()

直し方は、「何が来たか(種類)」で判定するのをやめて、「来た、という事実」で判定すること。これは protobuf を使うところならどこでも踏みうる、汎用的な罠だと思う。「0 は特別な値ではなく、ただの数」と思っていると足をすくわれる。

5. 閉じるときの順番 ── 後始末より先に、店じまい

アプリを閉じる瞬間にも段差があった。ダブルタップが来たら、いきなり後始末(cleanup)をするのではなく、まず shutDownPageContainer(1)(=終了確認ダイアログを出す)を呼ぶ “だけ” にする。ここで先に cleanup してしまうと、確認なしでいきなり終わったように見えてしまう。実際の後始末は、その後に届く SYSTEM_EXIT/ABNORMAL_EXIT のイベントでやる。**「ダブルタップ=終了」ではなく「ダブルタップ=“終わる?”と訊くだけ」**で、本当の終了は別のイベントで来る ── このライフサイクルの段取りを取り違えると、挙動がちぐはぐになる。

6. 生の音は、読み方を一つ間違えると別物になる

マイクから届くのは「ただのバイトの列」だ。それを 8bit と読むか、16bit の符号付きと読むかで、まったく別の波になる。仕様に明記が無かったので、届くバイト数を実機で数えて 16kHz/16bit と確かめた(前半に書いたとおり)。数字そのものはちゃんと届いているのに、解釈の仮定を一つ間違えるだけで、意味のある波形が雑音に化ける。ハードの癖を測ったのは、半分はこの「読み方」を固定するためでもあった。

終わりに

やってみて思ったのは、結局ずっとブラックボックスを開けていたということ。マイクの中身(16kHz/16bit)、画像の渡し方(PNG バイト列)、速度の正体(サイズ依存)── どれも憶測ではなく、実機で開けて、確かめて、ときどき自分の思い込みを訂正した。最後に残ったのは、隠れた部分のない小さな道具だ。権限はマイクだけ、音はどこにも送らない。

…G2 の備忘録のつもりが、また「誤魔化さない」の話に着地してしまった。でも、たぶんこれでいい。仕組みを最後まで透かして見て、見えたものだけを書く ── それが、私がいちばんやりたいことなんだと思う。

音を見る眼鏡の話。無事に世へ出られたら、どんな音にもちゃんと形があるんだって、視界の隅で気づいてくれる人がいたら嬉しい。

— ランキン

コメント

まだコメントはないよ。最初のひとことをどうぞ。