前編では Even G2 に BLE で直接つないで、グラスに日本語テキストを表示するところまで書きました。
後編では、前編でやらなかった新しい試みに進みます。

マイクの音声を受け取り、文字起こし(STT)をしてみます。
また、Even G2 特有の画像とリストの表示にも挑戦します!

後編でもつまづき(不具合)が幾つかありました。
それらについては、記事後半の付録で対処方法を紹介します。

結論から言うと、マイクの音声取得から文字起こし、画像のアニメーション、リストの表示まで、公式アプリなしで一通り動かせました!

マイクの音声を受け取る

マイクを有効にする手順には条件が 2 つあります。

  1. ページを表示している間しか音声が流れない。先に displayText でページを作る
  2. 有効化は off から on への変化で伝える。すでに on のときに on を送っても何も起きない
// lib/src/client/even_g2_client.dart
// _sendTo は指定した側へメッセージを書き込む
// (実装には右アーム専用の別名もあるが、記事では _sendTo に揃えた)
Future<void> startMicrophone() async {
  if (!_pageCreated) {
    throw StateError('マイクはページ表示中のみ動作する。先に displayText を呼ぶこと');
  }
  // 立ち下がりを作るため、常に off を先に送る
  await _sendTo(_right, msg.audioControl(
      enable: false, magicRandom: _nextMagicRandom()));
  await Future<void>.delayed(const Duration(milliseconds: 500));
  await _sendTo(_right, msg.audioControl(
      enable: true, magicRandom: _nextMagicRandom()));
}

届く音声は LC3 で符号化されています。
LC3 は Bluetooth LE Audio の音声コーデックです。

音声 notify で届く 200 バイトのチャンクを 40 バイトのフレーム 5 つに分け、LC3 デコーダを通して 16kHz の PCM にする流れの図
項目
サンプリング周波数16 kHz
フレーム長10 ms
1 フレーム40 B
notify 1 回約 200 B (40 B × 5 フレーム)
チャンネルモノラル

受信側は 40 B ずつ切ってデコーダに渡します。

// lib/src/client/even_g2_client.dart
void _onAudioChunk(Uint8List chunk) {
  // _audioChunkBytes = 200、_lc3FrameBytes = 40。
  // MTU が小さく 200 B 未満で届く機種があるため、短いほうに合わせる
  final usable =
      chunk.length < _audioChunkBytes ? chunk.length : _audioChunkBytes;
  if (usable < _lc3FrameBytes) return;

  // 左右両方から同一チャンクが届くため、直前と一致するものは廃棄している
  final last = _lastAudioChunk;
  if (last != null && listEquals(last, chunk)) {
    _lastAudioChunk = null;
    return;
  }
  _lastAudioChunk = Uint8List.fromList(chunk);

  final pcm = BytesBuilder();
  for (var offset = 0;
       offset + _lc3FrameBytes <= usable;
       offset += _lc3FrameBytes) {
    pcm.add(_lc3!.decodeFrame(
        Uint8List.sublistView(chunk, offset, offset + _lc3FrameBytes)));
  }
  _pcmController.add(pcm.toBytes());  // 開発アプリ側は pcmStream で受け取る
}

少々苦戦したことがありました。

BLE を介してコマンドは右アームだけへ送りました。
ですが、音声は左右アームのそれぞれから同じチャンクが上がってきました。

そのまま流すと文字起こしが二重になってしまいます。
なので、後発の音声データは同一チャンクかどうかを判定して廃棄するようにしました。

文字起こし(STT)をする

エンジンの比較

デコードした PCM をどのエンジンに渡すかを実機で比べました。

エンジンAndroid (Galaxy S24)iOS (iPhone 17 Pro)
whisper tiny (約 78 MB)反応は速いが誤字が多いAndroid より遅い。誤字は同等
OS ネイティブML Kit GenAI。最速で誤字も少ないSpeech framework。速度と精度ともに良好

結果として、どちらのプラットフォームでも OS ネイティブを採りました。
精度の点以外で、モデルをアプリに同梱しなくてよい点も実用上は大きいですね。

whisper tiny は端末を選ばない代わりに、日本語の漢字の当て間違いが目立ちました。

Note

OS ネイティブの STT は、Android では ML Kit GenAI、iOS では Speech framework が担っている

Android の実機では、次の要項を満たす必要があります。

要項必要な理由求められる時期満たさないときの挙動
AICore 対応端末ML Kit GenAI の音声認識が AICore 上で動くため常時認識機能が利用できない
認識機能のダウンロードモデルをアプリに同梱せず OS 側が持つため初回の認識開始前(OS が自動で行う)完了まで認識を開始できない
RECORD_AUDIO 権限(マニフェスト宣言 + 実行時許可)音声を BLE から渡す構成でも認識サービスが要求するため認識開始前ERROR_TYPE_INSUFFICIENT_PERMISSION で失敗する

なお、ML Kit GenAI の日本語対応は執筆時点で beta です。

呼び出しの流れ

比べられるように、エンジンは pcmStream を渡して結果をコールバックで受け取る形に揃えました。
呼び出し側は、ページを出す、エンジンをつなぐ、マイクを開ける、の順に実行します。

// 開発アプリ側から見た流れ
await client.displayText('Listening...');  // 音声はページ表示中しか流れない
await engine.start(
  pcm16Stream: client.pcmStream,
  onResult: (text) {
    setState(() => _transcript = text);    // 手元の画面に出す
    if (_mirrorToGlasses) _mirror(text);   // グラスへ返すかはアプリが決める
  },
  onError: (e) => _showMessage('文字起こしエラー: $e'),
);
await client.startMicrophone();

OS ネイティブのエンジンは、PCM をプラットフォームチャネルでネイティブ側へ渡し、結果をイベントで受け取ります。
認識中の途中経過(partial)と確定した文(final)が別々に届くので、確定分だけを積み上げます。

// example/lib/asr_engine.dart(NativeAsrEngine の一部)
// 結果のイベントを先に購読する。後から購読すると初期の認識結果を落とす
_eventSub = _events.listen((dynamic e) {
  switch (e['type']) {
    case 'partial':
      _partial = e['text'] as String? ?? '';
      onResult('$_finalized$_partial');   // 途中経過は上書きして見せる
    case 'final':
      final text = e['text'] as String? ?? '';
      if (text.isNotEmpty) _finalized.write(text);
      _partial = '';
      onResult(_finalized.toString());
  }
});

// (ここでネイティブ側の認識を開始する。省略)

_pcmSub = pcm16Stream.listen(
  (bytes) => unawaited(_channel.invokeMethod<void>('feed', bytes)),
  onError: onError,
);

推論はネイティブ側のスレッドで走ります。
Dart 側は PCM を渡して結果を受け取るだけで、メインスレッドを塞ぎません。
これは後述するフリーズを避けるうえでも効いています。

文字起こし結果は、スマホの画面とグラスの両方に出力してみました。

下の動画は、発話に合わせてスマホ側の文字起こし結果がリアルタイムに伸びていく様子です。

こちらは写真ですが、同時に Even G2 にもリアルタイムにテキストが出力されていきました。

Even G2 のレンズ越しに、文字起こし結果の日本語テキストが緑色で表示されている写真

Note

オンデバイス推論ライブラリ sherpa_onnx (より大きなモデル) も試したが、これは後述のフリーズを頻繁に引き起こしたために不採用になった

機能の拡張 1: 画像を描く

Even G2 はテキスト以外に、画像とリストも描けます。
ところが一次資料(前編で拠り所にした MentraOS の実装)には、この 2 つを実機で通す手順が書かれていませんでした。
メッセージの形は読み取れるのに、そのまま送っても何も表示されません。

手がかりになったのは g2-kit-unofficial というコミュニティ実装でした。
MIT ライセンスで、実機で確かめた手順と癖が文書として残されています。
この項と次項は、g2-kit-unofficial を参考にして組み立てて、実機で確かめ直したものです。

画像を描くための段階は 4 つです。

  1. 4 ビットのグレースケール BMP に変換する
  2. 4096 B ずつのフラグメントに分ける
  3. フラグメントごとにグラスの応答を待って次を送る
  4. 送信の試行ごとに転送 ID を 2 つ進める
8 ビットのグレースケール画像を 4 ビット BMP へ変換し、4096 バイトごとのフラグメントに分けて、1 つ送るごとにグラスの応答を待って次を送る流れの図

BMP への変換

段階 1 は Even G2 が受け取れる形式の BMP への変換です。

16 階調のパレットを 17 刻みで作り、8 ビットの階調を 4 ビットへ落として 1 バイトに 2 画素詰めます。
行はボトムアップで並べ、4 バイト境界までゼロで埋めます。

// lib/src/display/bmp.dart(encodeGray4Bmp の一部)
for (var i = 0; i < 16; i++) {
  final v = i * 17;                     // 0, 17, 34, ... 255 の 16 階調
  bmp[54 + i * 4] = v;                  // パレットは B, G, R, 0 の順
  bmp[54 + i * 4 + 1] = v;
  bmp[54 + i * 4 + 2] = v;
}

for (var row = 0; row < height; row++) {
  final srcOffset = (height - 1 - row) * width;         // 下の行から詰める
  final rowOffset = pixelDataOffset + row * rowStride;  // rowStride は 4 バイト境界
  for (var col = 0; col < width; col++) {
    final index4 = pixels[srcOffset + col] >> 4;        // 8 ビットを 4 ビットへ
    if (col.isEven) {
      bmp[rowOffset + (col >> 1)] = index4 << 4;        // 偶数列が上位ニブル
    } else {
      bmp[rowOffset + (col >> 1)] |= index4;
    }
  }
}

Note

階調のある画像は、量子化の前に Floyd-Steinberg のディザを掛けると縞が目立たない (dither: true)。

応答待ちモード

残りの段階 2〜4 は転送に関わる処理です。
BMP 化した本体を 4096 B ごとに切り、送り方は 2 つのモードに分かれます。
フラグメントごとにグラスの応答を待つ「応答待ちモード」と、待たずに送り続ける「高速モード」です。

// lib/src/client/even_g2_client.dart(updateImage と _transferImage の一部)
Future<void> updateImage({
  required int containerId,
  required Uint8List gray8,
  required int width,
  required int height,
  bool dither = false,
  bool awaitAck = true,
}) {
  final bmp = encodeGray4Bmp(gray8, width, height, dither: dither);
  // 転送は直列化する。前の転送が終わってから次を始める
  final transfer = _imageChain.then((_) => awaitAck
      ? _transferImage(containerId: containerId, bmp: bmp)        // 応答を待つ
      : _transferImagePaced(containerId: containerId, bmp: bmp)); // 高速モード
  _imageChain = transfer.catchError((_) {});
  return transfer;
}

Future<void> _transferImage({required int containerId, required Uint8List bmp}) async {
  for (var attempt = 1; attempt <= _imageMaxAttempts; attempt++) {  // 既定 3 回
    // 隣接する ID は直前の失敗状態を引き継ぐことがあるため、常に 2 つ進める。
    // 失敗した転送を捨てて別の ID でやり直せるのも、この 2 つ進める形のおかげ
    _imageSessionId = (_imageSessionId + 2) & 0xFF;
    final sessionId = _imageSessionId;
    try {
      for (var offset = 0; offset < bmp.length; offset += _imageFragmentSize) {
        final end = offset + _imageFragmentSize < bmp.length
            ? offset + _imageFragmentSize
            : bmp.length;
        final index = offset ~/ _imageFragmentSize;
        final ackFuture = _waitForImageAck(sessionId, index);  // 送る前に張っておく
        await _sendTo(_right, msg.updateImageRawData(
          containerId: containerId,
          sessionId: sessionId,
          totalSize: bmp.length,
          fragmentIndex: index,
          rawData: Uint8List.sublistView(bmp, offset, end),
          magicRandom: _nextMagicRandom(),
        ));
        final ack = await ackFuture;                          // 応答を待ってから次へ
        if (ack.errorCode != 4) {
          throw StateError('画像フラグメント拒否 errorCode=${ack.errorCode}');
        }
      }
      return;  // 全フラグメント成功
    } on TimeoutException {
      if (attempt == _imageMaxAttempts) rethrow;
    } on StateError {
      if (attempt == _imageMaxAttempts) rethrow;
    }
  }
}

応答の errorCode の値が 4 なら受理・成功を表します。

Important

成功コードはメッセージ種別ごとに異なる。その他には例えば次のものがある。

  • ページ作成 : 0
  • テキスト更新 : 8

高速モード

アニメーションのように連続で描くときは高速モード(_transferImagePaced)を使います。

// 開発アプリ側から見た流れ
await client.updateImage(
  containerId: 3,
  gray8: pixels, width: 30, height: 30,
  awaitAck: false,   // 応答を待たない高速モード
);

BMP を 4096 バイトごとに分割するところまでは、応答待ちモードと同じです。

違いは、フラグメントを送った後に応答を待たないことです。
200 ms の待ちが入るのは、1 枚の BMP が複数のフラグメントに分かれたときの、フラグメント間だけです。

この記事のデモ画像(30 × 30、50 × 50)はどちらも 1 フラグメントに収まるため、フラグメント間の待ちが発生しません。
そのため 30 × 30 では、200 ms 間隔の理論上限(毎秒 5 コマ)を上回る FPS が出ました。
より大きな画像でも高速モードは使えますが、フラグメント間の待ちのぶん転送は遅くなります。
取りこぼしは許容して、失敗しても送り直しません。

次の表の FPS は実測結果になります。

画像サイズ応答待ちモード高速モード
30 × 306 から 7 FPS10 から 11 FPS
50 × 503 FPS3 から 3.5 FPS

下の動画では、50 × 50 の画像を高速モードでアニメーションさせています。

実機で分かった制約が 2 つあります。

  1. 画像コンテナの寸法と BMP の寸法が一致していないと、グラスは受け取らずエラーを返す
  2. 表示中のページに存在しないコンテナ ID へ送った場合も同じく拒否される

どちらも正しい挙動で、送信先の管理はアプリ側の責務になります。

機能の拡張 2: リストを描く

リストは、「スクロール」と「選択」をグラス側のファームウェアが受け持ちます。

アプリが渡すのはコンテナの定義(位置や大きさ、項目の文字列)だけです。
タッチ操作の解釈、選択行のハイライト、スクロール位置の管理はグラス側で制御します。

アプリには「何番目が選ばれたか」というイベントだけが通知されます。

2 段階の作成

リストは作成の仕方に癖があります。

大きなリストは、いきなり本来のサイズで作ると表示されません。
g2-kit-unofficial が実践していた回避手順は、小さく作ってすぐに作り直すというものでした。

リストを幅 280 高さ 130 に縮めて作成し、直後に本来のサイズへ作り直す 2 段階の手順を示した図
段階送る情報サイズ
1ページ作成幅 280、高さ 130 に収まるよう縮める
2ページ再構築本来のサイズ

コードは次のようになります。作成のときだけコンテナを縮めています。

// lib/src/client/even_g2_client.dart(showPage の一部)
if (!_pageCreated) {
  if (page.lists.isNotEmpty) {
    // 縮小サイズで作成
    await _sendTo(_right, msg.createStartupPage(
      texts,
      listContainers: [for (final l in page.lists) _shrinkForCreate(l)],
      imageContainers: page.images,
      magicRandom: _nextMagicRandom(),
      appId: appId,
    ));
    _pageCreated = true;
    // 直後に本来のサイズへ作り直す
    await _sendTo(_right, msg.rebuildPage(
      texts,
      listContainers: page.lists,
      imageContainers: page.images,
      magicRandom: _nextMagicRandom(),
    ));
    return;
  }
  // リストを含まないページは 1 回の作成で済む(省略)
}

// 作成時だけ幅と高さに上限を掛ける
msg.G2ListContainer _shrinkForCreate(msg.G2ListContainer l) =>
    msg.G2ListContainer(
      width: l.width > 280 ? 280 : l.width,
      height: l.height > 130 ? 130 : l.height,
      // x, y, containerId, items などは元の値をそのまま渡す
      x: l.x,
      y: l.y,
      containerId: l.containerId,
      items: l.items,
    );

操作イベント

開発アプリ側からはシンプルなコードでイベント通知を受信できるようにしました。

// 開発アプリ側から見た流れ
final items = ['Apple', 'Banana', 'Cherry'];

await client.showPage(G2Page(lists: [
  G2ListContainer(
    x: 0, y: 0, width: 560, height: 280,
    containerId: 4,
    isEventCapture: true,  // タッチをこのリストに割り当てる(既定は false)
    items: items,
  ),
]));

client.events.listen((e) {
  final event = e.event;  // イベントは (side, event) の組で届く
  if (event is ListSelectEvent) {
    final label = items[event.selectedItemIndex];  // 名前は index から引く
  }
});

これで 560 × 280 のリストが、表示・スクロール・選択まで動きました。
項目の入れ替えはページ再構築で差し替えます。

下の動画では、グラスのアームをスライド操作して行を移動し、そのイベントを受け取ってスマホの画面に項目名を表示しています。

実機の癖

実機での動作確認で得たリストに関わる “癖” が 3 つあります。

内容対処
選択イベントの項目名index は必ず届くが、項目名は省略されることがあるindex から自分のリストデータを引く
行の高さファームウェアが決める。アプリからは指定できないコンテナの高さを行高の整数倍に合わせる
端の見切れ高さが整数倍でないと、スクロール端で最終行が半分だけ見える全項目が一度に収まる高さを取ると緩和する

付録: 不具合の対処

ここまでの機能を実装する過程で、後編でも 3 件のつまづき(不具合)が起こりました。
症状と対処方法を紹介します。

不具合 1: ページを作り直すとフリーズする

  • 症状: 画面切り替えの 2 周目でグラスだけが反応しなくなる。BLE リンクは生きている
  • 原因: ページを閉じてから作り直す操作
  • 対処: 接続中に 1 回だけページを作り、以降は内容を差し替える

前編の displayText と、後編の showPage がその形になっているのは、これが理由です。

不具合 2: 書き込みのタイムアウトでフリーズする

  • 症状: 片アームへの書き込みが全滅し、アプリを再起動するまで戻らない
  • 原因: BLE プラグイン(Android の universal_ble 2.1.0)の完了待ちが「同時に 1 件」の前提。タイムアウトで放置された未完了の書き込みが、以後の完了通知を壊す
  • 対処: プラグインに timeout を渡さない。外側から .timeout() も掛けず、内部キューの既定 10 秒に任せる

なお、「放置された未完了の書き込みで完了通知が壊れたまま戻らなくなる」件は、universal_ble 2.1.1 (2026-07-27) で修正されました(ソース差分でのみ確認)。

ただし完了通知の照合が書き込み 1 件ずつの前提であることは 2.1.1 でも変わらないため、timeout を渡さずキューに任せる対処はそのまま有効です。

Important

この記事の検証は universal_ble 2.1.0 で行った。

2.1.1 では検証していない。

不具合 3: メインスレッドが塞がるとフリーズする

  • 症状: iOS は切断される。Android はページ操作だけが無反応になる
  • 原因: ハートビートを Timer.periodic で送っているため、メインスレッドが塞がると発火しない
  • 対処: 重い推論をメインスレッドで回さない

前編でハートビートの間隔を短めに取ったのも、この遅れを見込んでのことです。

これらを踏まえ、パッケージ側 (even_g2_ble) の方針は次の形に落ち着きました。

  • 表示のコマンドは送りっぱなしにする
  • ページが失われたことはイベントで通知するだけにする
  • 再表示と再接続はアプリが決める
パッケージはページ喪失をイベントで通知するだけで、再表示と再接続は開発アプリが行うという責務の分担を示した図

終了通知には正常終了と異常終了のコードがありますが、ファームウェアはページが健全なときにも終了通知を送ることがあります。
ですので、通知だけでは「ユーザが終了させた」のか「不具合で落ちた」のかを確定できません。

パッケージが勝手に復元すると、ユーザが閉じたページを押し返すことになります。
開発アプリ側はページ喪失と切断の 2 つを受け取り、戻すかどうかを自分で決めるようにしました。

// 開発アプリ側から見た流れ
client.events.listen((e) {
  final event = e.event;
  // ページ喪失は 3 つの形で届く。6=abnormalExit、7=systemExit
  final pageLost = event is PageShutdown ||
      (event is DeviceEvent &&
          (event.eventType == 6 || event.eventType == 7)) ||
      (event is TextUpdateResult && event.errorCode == 9);
  if (pageLost) {
    // グラス側でページが終わった。パッケージは何も復元しない
    setState(() => _pageAlive = false);
  }
});

client.stateStream.listen((state) {
  if (state == G2ConnectionState.disconnected) {
    setState(() => _pageAlive = false);
  }
});

// 復帰の判断はアプリが持つ。自動で呼ぶか、ボタンに割り当てるかを選べる
Future<void> _resume() async {
  if (client.state == G2ConnectionState.disconnected) {
    await client.connect();
  }
  await client.showPage(_myPage);  // 接続が戻っても表示は戻らない。送り直す
}

まとめ

Even G2 に BLE で直接つないで、グラスに「画像の描画とアニメーション」、「グラス単体で完結するリスト操作」、「マイクの音声取得」が出来るようになりました。
また、マイクからの音声を OS ネイティブ機能だけで文字起こし (STT) も実用レベルで出来るようになりました。

深掘りすれば、色々なことが自作できる Even G2 はとても素晴らしいデバイスだと思います。
みなさんも、Even G2 で色々なアプリを作って遊び尽くしましょう!

本記事がそのための助力になれば嬉しいです。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる