お問い合わせ
  • Tech

Flutterの音声コントロール ~セマンティック実装や音声認識について~

本記事ではiOSのアクセシビリティ設定にある「音声コントロール」にフォーカスし

Flutter製アプリにおける「音声コントロール」を動作させる方法と、
派生として「音声認識機能の実装」についてサンプルコードを交えつつ紹介します。

はじめに ~アクセシビリティラベル~

iOSのアプリ審査時にアクセシビリティのサポートを明記できる「アクセシビリティラベル」という項目があるのをご存知でしょうか。

この項目は、ユーザー側はアプリをダウンロードする前にそのアプリがアクセシブルかどうかを把握することができ、デベロッパ側はアプリが対応している機能についてユーザにより適切に情報を提供し、説明できるものになっています。

公式の説明)

アクセシビリティラベルの概要

※Apple Store Connectの画面でいうと「配信」タブの中「信頼と安全」のなかの「アプリのアクセシビリティ」で設定できます。

そもそも「アクセシビリティ」とはアクセスのしやすさ、転じて、製品やサービスの利用しやすさを意味します。高齢者や障害の有無などにかかわらず、使いやすい・利用しやすいものかの指標となる言葉です。

「アクセシビリティラベル」の項目を意識することは、品質の良いアプリを作成につながります。

今回は「アクセシビリティラベル」の中でも「音声コントロール」についてまとめたり、「音声コントロール」に関わる実装をしてみようと思います。

「音声コントロール」とは何か

「音声コントロール」とは先ほどの「アクセシビリティラベル」ページには

ユーザは、音声を使用してタップ、スワイプ、クリック、入力などを行い、アプリを動かしたり操作したりできます。この機能はApple TVApple Watchではサポートされません。

と書かれています。

つまり「音声でもアプリを操作できる」ことが求められています。

では開発者である私たちは「音声コントロール」を実現するために、特別な実装が必要なのかというと必ずしもそうではないです。iOSの設定の「音声コントロール」を有効化して、アプリを操作できるかどうかが、対応できているか否かの分岐点となります。

「音声コントロール」の有効化

「音声コントロール」を利用するには、設定で有効化する必要があります
※以下の画面は参考情報となります(iOS : 18.5,  端末 : iPhone12)

設定場所「設定」→「アクセシビリティ」→「音声コントロール」と移動し、設定をONにする。

設定完了すると、常に音声認識される形となります。試しに普段利用されているアプリや開発中のアプリを開いてみて「〇〇をタップ」のように画面に表示されている文言をそのまま喋ってみましょう。すると、「〇〇」がタップされた挙動をしてくれるのではないでしょうか。

Flutterで「音声コントロール」をうまく動かす ~ セマンティックの実装 ~

例えばFlutter製のアプリで音声コントロールを試してみるとどうでしょうか?比較的動いたのではないでしょうか。

音声コントロールがうまく動作するか否かは、セマンティック(semantics)の実装が適切に実施されているかによります。

一見適切に動作しているように見えるのは、ボタンのラベルなどに文字列を指定することで、セマンティック情報の指定もできているからです。

ElevatedButton(
onPressed: () =>_showDialog(context, '送信ボタン'),
child:constText('送信'),
),

例えばElevatedButtonなどはchildのTextで指定した文字列がそのままセマンティックの情報となります。

ただ、当然と言えば当然ですが、何も意識しなくても「音声コントロール」が自然とOKになる、というわけではなく、以下のような点には注意が必要になります

  • ラベルがない要素でタップ機能があるもの
    • アイコン
    • 画像
  • 標準のライブラリを利用せずにタップ機能などを追加した箇所
    • GestureDetector etc

いい例と悪い例を実装した、Flutter製アプリの音声コントロールの様子は以下となります。

ソースコード

class VoiceControl extends StatelessWidget {
  const VoiceControl({super.key});

  void _showDialog(BuildContext context, String label) {
    showDialog<void>(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('タップされました'),
        content: Text('「$label」がタップされました'),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(),
            child: const Text('閉じる'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Gap(24),
            // ✅ 良い例: テキスト付きボタン(音声コントロールでラベルが認識される)
            const Text('✅ 良い例', style: TextStyle(fontWeight: FontWeight.bold)),
            const Gap(8),
            ElevatedButton(
              onPressed: () => _showDialog(context, '送信ボタン'),
              child: const Text('送信'),
            ),
            const Gap(8),
            TextButton(
              onPressed: () => _showDialog(context, 'キャンセルボタン'),
              child: const Text('キャンセル'),
            ),
            const Gap(8),
            // ✅ 良い例: tooltip あり IconButton(音声コントロールでラベルが認識される)
            IconButton(
              tooltip: 'お気に入りに追加',
              icon: const Icon(Icons.favorite_border),
              onPressed: () => _showDialog(context, 'お気に入りに追加'),
            ),
            const Gap(8),
            // ✅ 良い例: Semantics でラベルを付与した GestureDetector
            Semantics(
              label: 'カスタムタップエリア',
              button: true,
              child: GestureDetector(
                onTap: () => _showDialog(context, 'カスタムタップエリア'),
                child: Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
                  decoration: BoxDecoration(
                    color: Colors.blue.shade100,
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: const Text('カスタムタップエリア(Semantics あり)'),
                ),
              ),
            ),
            const Gap(32),
            // ❌ 悪い例
            const Text(
              '❌ 悪い例(音声コントロールで操作しにくい)',
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
            const Gap(8),
            // ❌ 悪い例: tooltip なし IconButton(音声コントロールでラベルなし)
            IconButton(
              icon: const Icon(Icons.delete),
              onPressed: () => _showDialog(context, '削除ボタン(tooltipなし)'),
              // tooltip なし → 音声コントロールでボタン番号しか表示されない
            ),
            const Gap(8),
            // ❌ 悪い例: Semantics なし GestureDetector(音声コントロールで認識されない)
            GestureDetector(
              onTap: () => _showDialog(context, 'ラベルなしタップエリア'),
              child: Container(
                padding:
                    const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
                decoration: BoxDecoration(
                  color: Colors.red.shade100,
                  borderRadius: BorderRadius.circular(8),
                ),
                child: const Text('タップエリア(Semantics なし)'),
              ),
            ),
            const Gap(8),
            // ❌ 悪い例: ラベルなし画像ボタン
            GestureDetector(
              onTap: () => _showDialog(context, '画像ボタン(altなし)'),
              child: const Icon(Icons.image, size: 48, color: Colors.grey),
              // Semantics なし → 音声コントロールで識別不可
            ),
            const Gap(32),
            // ❌ 悪い例: ラベルなし画像ボタン
            const Text(
              '✅ 改善(「画像タップ」)というと動作する。',
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
            GestureDetector(
              onTap: () => _showDialog(context, '改善の画像'),
              child: const Icon(
                Icons.image,
                size: 48,
                color: Colors.grey,
                semanticLabel: '画像',
              ),
              // Semantics あり → 音声コントロールで識別可能
            ),
          ],
        ),
      ),
    );
  }
}

GitHubのリンク

またセマンティックの対応漏れがないようにするには実装時にMaterialAppの「showSemanticsDebugger」をtrueにするのが良さそうです。「showSemanticsDebugger」をtrueにするとセマンティックで設定した情報を画面で確認することができます。

void main() {
  runApp(
    MaterialApp(
      showSemanticsDebugger: true, // デバッグ時に有効にする
      home: MyWidget(),
    ),
  );
}

「showSemanticsDebugger」をtrueにした様子

参考

アプリ独自の音声認識を実装する方法

ここからは正確にはiOSの「音声コントロール」とは関係ないのですが、
派生として「音声認識」をアプリ側で実装する方法について記載したいと思います。

音声認識の実装方法

音声入力ボタンを押した後に「画面遷移して」という声を入力して後、アプリが画面遷移をするという機能を考えてみます。

音声認識の実装方法については(2026年4月時点)以下のような方法が考えられます。

① 汎用的な音声認識パッケージを使う

汎用的な音声認識パッケージとはFlutterだとspeech_to_text です。

メリット : 比較的実装が容易
デメリット : 汎用的なため音声を元にした複雑な処理はできない可能性がある

② Porcupine / Rhino を利用する

Porcupine / Rhino とは特定のキーワード(Wake Word)の検知や、意図抽出(Inference)に特化したエンジンです
メリット : オフラインで動作・「Hey Siri」などのように特定の用途に絞った呼びかけを高い精度で実施できる
デメリット : 利用するにはアカウント登録などが必要。商用利用にはライセンス費用がかかる場合がある
※こちら少し試しましたが、2026年4月時点。無料で試す場合もアカウント作成後に利用までに審査がありました。

参考

③ 音声入力をカバーしているLLMを利用する

ローカルLLMを利用する場合と、オンラインのAPIを利用する場合があります。
※メンテナンスが長くされていないようだが、flutterのパッケージではvosk_flutter が調査では引っかかりました。

メリット : 複雑な処理を実現できる可能性がある。
デメリット : 実装が複雑になったり、APIの利用料発生する可能性がある。

今回は参考で①の実装を後述したいと思います!

アプリ独自の音声認識の検証結果とサンプルコード

実装結果

「音声入力ボタン」を押す必要はありますが、speech_to_text を使って音声で画面遷移できることができました。

実装の中身

class SpeechToTextAction extends HookWidget {
  const SpeechToTextAction({super.key});

  @override
  Widget build(BuildContext context) {
    // インスタンスを効率的に再利用するためuseMemoizedでラップ
    final speechToText = useMemoized(
      SpeechToText.new,
    );
    final isSpeechAvailable = useState(false);
    final isListening = useState(false);
    // 二重実行を防ぐためのフラグ
    // 値が変わった際に再ビルドは必要ないのでuseRefで保持
    final hasNavigatedBySpeech = useRef(false);
    final recognizedText = useState('ここに音声認識結果が表示されます');
    final statusText = useState('待機中');

    Future<void> startListening() async {
      if (!isSpeechAvailable.value) {
        final available = await speechToText.initialize(
          onStatus: (status) {
            statusText.value = 'status: $status';
            if (status == 'done' || status == 'notListening') {
              isListening.value = false;
            }
          },
          onError: (errorNotification) {
            statusText.value = 'error: ${errorNotification.errorMsg}';
            isListening.value = false;
          },
        );

        isSpeechAvailable.value = available;

        if (!available) {
          statusText.value = '音声認識が利用できません';
          return;
        }
      }

      hasNavigatedBySpeech.value = false;
      await speechToText.listen(
        localeId: 'ja_JP',
        onResult: (result) {
          final words = result.recognizedWords;
          recognizedText.value = words.isEmpty ? '認識できませんでした' : words;

          // 今回はうまく「'画面遷移して」で漢字変換され認識できている。
          // 文言によっては表記揺れを補えるようパターンを複数用意する必要があるかも。
          if (!hasNavigatedBySpeech.value && words.contains('画面遷移して')) {
            hasNavigatedBySpeech.value = true;
            context.go('/${SpeechResultView.path}');
          }
        },
      );

      isListening.value = true;
      statusText.value = '音声入力中...';
    }

    Future<void> stopListening() async {
      await speechToText.stop();
      isListening.value = false;
      statusText.value = '停止しました';
    }

    useEffect(
      () {
        return speechToText.cancel;
      },
      [speechToText],
    );

    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Text('音声入力', style: TextStyle(fontWeight: FontWeight.bold)),
          const Gap(8),
          ElevatedButton.icon(
            onPressed: isListening.value ? stopListening : startListening,
            icon: Icon(isListening.value ? Icons.stop : Icons.mic),
            label: Text(isListening.value ? '音声入力を停止' : '音声入力を開始'),
          ),
          const Gap(8),
          ElevatedButton(
            onPressed: () => context.go('/${SpeechResultView.path}'),
            child: const Text('画面遷移ボタン'),
          ),
          const Gap(12),
          Text(
            '入力結果: ${recognizedText.value}',
            textAlign: TextAlign.center,
          ),
          const Gap(4),
          Text(
            '状態: ${statusText.value}',
            style: const TextStyle(fontSize: 12, color: Colors.black54),
          ),
        ],
      ),
    );
  }
}

GitHubのリンク

※ 実際に試してみる際はspeech_to_text使う上でのAndroidとiOSでの設定をお忘れなく

Android
speech_to_texのAndroidの設定

iOS
speech_to_texのiOSの設定

まとめ

本記事では「アクセシビリティラベル」の「音声コントロール」の話を元にセマンティック実装と音声認識の実装方法についてサンプルを交えながら実装方法などを検討しました。

書かれている内容をポイントをまとめると以下となります。

  • iOSの「アクセシビリティラベル」の「音声コントロール」とはそもそも何か
  • セマンティック実装の重要性と注意点
    • 注意 : ラベルがない要素でタップ機能があるもの
    • 注意 : 標準のライブラリを利用せずにタップ機能などを追加した箇所
  • アプリて音声認識を実装する方法案

本記事の内容が何かしらみなさんの参考になれば幸いです。

ドコドア エンジニア部

ドコドア エンジニア部

Flutterなどの技術を活用し、ユーザーにとって価値ある高品質なモバイルアプリ・Webアプリの開発に取り組んでいます。
このブログでは、アプリ開発の現場で培ったフロントエンド、バックエンド、インフラ構築の知識から生成AI活用のノウハウまで、実践的な情報をアプリ開発に悩む皆様へ向けて発信しています!
【主な技術スタック】 Flutter / Firebase / Svelte / AWS / GCP / OpenAI API

Contact Us

Web制作、Webマーケティング、SFA・MA導入支援に関するお悩みがある方は、お気軽にご相談ください。

お問い合わせ・ご相談

ホームページ制作、マーケティングにおける
ご相談はお気軽にご連絡ください。

資料請求

会社案内や制作実績についての資料を
ご希望の方はこちらから。

お電話でのお問い合わせ

お電話でのご相談も受け付けております。

※コールセンターに繋がりますが、営業時間内は即日
担当より折り返しご連絡をさせて頂きます。

9:00-18:00 土日祝休み

電話する 無料相談はこちら