riverpodとSpeechToTextを組み合わせる

## はじめに
前回の記事「Flutterの音声認識を色々試してみる」では、`speech_to_text` プラグインを使い
- **SpeechToTextPage** と **SpeechToTextService** に処理を分割し、基本的な音声認識フローを実装  
- `listenFor`／`pauseFor` で連続認識時間や無音停止タイミングを調整 
といった実験を行いました。

https://qiita.com/mura_mattyo/items/10d9b18a96ca36e78736

音声認識をそのまま各ファイルに書いて行ったり、サービス化したものを呼び出してFlutterアプリに組み込み動かしてみると、
 
- 画面遷移時に音声認識が停止しなかった
- 停止後のリソース解放漏れでクラッシュ  

といった保守性・安定性の問題が残りました。  

そこで本記事では、上司の助言もあって **Riverpod** を活用し、  
音声認識の状態管理・自動再起動ロジックを Provider に一元化した実装例をご紹介します。

## riverpodとは
Riverpod は、Flutter で状態管理（State Management）を行うためのライブラリです。

https://riverpod.dev/ja/

 **「Provider」** の進化版ともいえるツールで、以下の特徴があります。

- **グローバルかつ安全**  
  Widget ツリーに依存せず、`ProviderScope` の下であればいつでも利用できる  
- **型システムとの親和性**  
  Dart の型推論を活かし、コンパイル時に依存関係の不整合を検出可能  
- **テストがしやすい**  
  Mock の差し替えや依存の注入が簡単  
- **ホットリロード対応**  
  ホットリロードしても Provider の状態がリセットされず、開発効率が向上  

## 音声認識と組み合わせる
### 課題の整理
-  `listen`／`stop` を `initState`／`dispose` に直書きすると、  画面遷移時に停止が漏れたり、停止後もリソースが残ってクラッシュしたりする  
- `cancel` と `stop` の違いがあいまいで、手動停止後の自動再開制御が難しい  
- 複数画面で同じロジックをコピー＆ペーストすると保守性が落ちる  

### Riverpod での管理方針
1. **StateNotifier＋StateNotifierProvider** で一元管理  
2. **initializeSpeech()** を呼び分けて、初回のみ `_speechToText.initialize()`  
3. **startListening()／stopListening()／cancelListening()** を明確に使い分け  
4. **_statusListener**／**_errorListener** で自動再起動やリトライを実装 
5. **CommandHandler** を組み込み、認識文字列に応じた音声コマンド機能を提供

どのように実装するかの方針が決まったので実際に実装していきます。

## 実装
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:speech_to_text/speech_to_text.dart';
import 'package:speech_to_text/speech_recognition_error.dart';
import 'package:flutter/material.dart';

/// 状態クラス
class SpeechState {
  final bool isListening;
  final bool isInitialized;
  final String recognizedWords;

  SpeechState({
    required this.isListening,
    this.isInitialized = false,
    this.recognizedWords = '',
  });

  SpeechState copyWith({
    bool? isListening,
    bool? isInitialized,
    String? recognizedWords,
  }) =>
      SpeechState(
        isListening: isListening ?? this.isListening,
        isInitialized: isInitialized ?? this.isInitialized,
        recognizedWords: recognizedWords ?? this.recognizedWords,
      );
}

class SpeechStateNotifier extends StateNotifier<SpeechState> {
  final SpeechToText _speech;
  final CommandHandler commandHandler;

  // 自動再起動ロジック用
  bool _shouldRestart = true;
  final Duration _listenFor = const Duration(seconds: 30);
  final Duration _pauseFor = const Duration(seconds: 4);
  final Duration _restartDelay = const Duration(milliseconds: 100);
  final int _maxErrorRetry = 5;
  int _errorCount = 0;

  SpeechStateNotifier(this._speech)
      : commandHandler = CommandHandler(),
        super(SpeechState(isListening: false));

  /// 初回のみ initialize
  Future<void> initializeSpeech() async {
    if (!state.isInitialized) {
      final available = await _speech.initialize(
        onError: _errorListener,
        onStatus: _statusListener,
      );
      state = state.copyWith(isInitialized: available);
    }
  }

  Future<void> startListening() async {
    // 前回結果をクリア
    state = state.copyWith(recognizedWords: '');
    try {
      _shouldRestart = true;
      await _speech.listen(
        onResult: (r) {
          state = state.copyWith(recognizedWords: r.recognizedWords);
          commandHandler.handleCommand(r.recognizedWords);
        },
        localeId: 'ja_JP',
        listenFor: _listenFor,
        pauseFor: _pauseFor,
        partialResults: true,
        onDevice: true,
      );
      state = state.copyWith(isListening: true);
      _errorCount = 0;
    } catch (_) {
      _errorCount++;
      state = state.copyWith(isListening: false);
    }
  }

  Future<void> stopListening() async {
    _shouldRestart = false;
    try {
      if (state.isListening) {
        await _speech.stop();
      }
    } finally {
      state = state.copyWith(isListening: false, recognizedWords: '');
      // 再開を許可するなら enableRestart() を呼ぶ
    }
  }

  Future<void> cancelListening() async {
    _shouldRestart = false;
    try {
      if (state.isListening) {
        await _speech.cancel();
      }
    } finally {
      state = state.copyWith(isListening: false);
      _shouldRestart = true;
    }
  }

  /// プラグインの状態変化を監視、自動再起動
  void _statusListener(String status) {
    final listening = status == SpeechToText.listeningStatus;
    state = state.copyWith(isListening: listening);
    if (!_shouldRestart) return;

    if (status == SpeechToText.doneStatus ||
        status == SpeechToText.notListeningStatus) {
      Future.delayed(_restartDelay, () {
        if (_shouldRestart) startListening();
      });
    }
  }

  /// エラーリスナーでリトライ
  void _errorListener(SpeechRecognitionError err) {
    state = state.copyWith(isListening: false);
    if (_errorCount < _maxErrorRetry) {
      _errorCount++;
      startListening();
    }
  }
}

/// コマンド登録用ハンドラ
class CommandHandler {
  final List<Map<String, VoidCallback>> _scopes = [{}];

  void addCommands(Map<String, VoidCallback> cmds, {int priority = 0}) {
    while (_scopes.length <= priority) _scopes.add({});
    _scopes[priority].addAll(cmds);
  }

  void removeCommands(Map<String, VoidCallback> cmds, {int priority = 0}) {
    if (priority < _scopes.length) {
      cmds.keys.forEach(_scopes[priority].remove);
    }
  }

  void handleCommand(String words) {
    for (final scope in _scopes.reversed) {
      for (final entry in scope.entries) {
        if (words.contains(entry.key)) {
          entry.value();
          return;
        }
      }
    }
  }
}

/// Provider 定義
final speechToTextProvider = Provider((_) => SpeechToText());
final speechStateProvider =
    StateNotifierProvider<SpeechStateNotifier, SpeechState>(
  (ref) => SpeechStateNotifier(ref.read(speechToTextProvider)),
);
```
  
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutterapptest/speech_state.dart';

class HomePage extends ConsumerStatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  ConsumerState<HomePage> createState() => _HomePageState();
}

class _HomePageState extends ConsumerState<HomePage> {
  @override
  void initState() {
    super.initState();
    _startSpeech();
    _addCommands();
  }

  @override
  void dispose() {
    _stopSpeech();
    _removeCommands();
    super.dispose();
  }

  /// 音声認識を開始する。
  Future<void> _startSpeech() async {
    if (!mounted) return;
    final SpeechStateNotifier speechStateNotifier =
        ref.read(speechStateProvider.notifier);
    await speechStateNotifier.initializeSpeech();
    if (!mounted) return;
    await speechStateNotifier.startListening();
  }

  /// 音声認識を停止する。
  Future<void> _stopSpeech() async {
    if (!mounted) return;
    final SpeechStateNotifier speechStateNotifier =
        ref.read(speechStateProvider.notifier);
    await speechStateNotifier.cancelListening();
    if (!mounted) return;
    await speechStateNotifier.stopListening();
  }

  /// コマンドを登録する。
  void _addCommands() {
    final SpeechStateNotifier speechStateNotifier =
        ref.read(speechStateProvider.notifier);
    speechStateNotifier.commandHandler.addCommands({
      "こんにちは": () {
        debugPrint("音声コマンド: こんにちは");
      },
    });
  }

  /// コマンドを登録解除する。
  void _removeCommands() {
    final SpeechStateNotifier speechStateNotifier =
        ref.read(speechStateProvider.notifier);
    speechStateNotifier.commandHandler.removeCommands({
      "こんにちは": () {},
    });
  }

  @override
  Widget build(BuildContext context) {
    final s = ref.watch(speechStateProvider);

    return Column(
      children: [
        if (s.isListening || s.recognizedWords.isNotEmpty) ...[
          Container(
            color: Colors.black54,
            padding: EdgeInsets.all(4),
            child: Text(
              s.isListening ? '🔊 音声認識中…' : '🔈 停止中…',
              style: const TextStyle(
                  color: Colors.white, fontWeight: FontWeight.bold),
            ),
          ),
          if (s.recognizedWords.isNotEmpty)
            Container(
              color: Colors.black26,
              padding: EdgeInsets.all(4),
              child: Text('認識結果: ${s.recognizedWords}',
                  style: const TextStyle(color: Colors.white)),
            ),
        ],
        ElevatedButton(
          onPressed: () async {
            final ctrl = ref.read(speechStateProvider.notifier);
            await ctrl.initializeSpeech();
            await ctrl.startListening();
          },
          child: const Text('開始'),
        ),
        ElevatedButton(
          onPressed: () =>
              ref.read(speechStateProvider.notifier).stopListening(),
          child: const Text('停止'),
        ),
      ],
    );
  }
}
```
実際のプロジェクトのコードをそのまま載せることができないので、シンプルに改変した画面と音声認識のコードを用意しました。

![スクリーンショット 2025-06-12 153152.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3904804/540a316f-964b-4a36-b67e-7ec6863ed177.png)

- `Riverpod＋StateNotifier` で音声認識の初期化・開始・停止・自動再起動・エラーリトライを１つのクラスに集約
- `ConsumerStatefulWidget` を使い、`initState／dispose` で画面ライフサイクルに紐づけ
- `CommandHandler` を組み合わせることで「○○と言ったら△△を実行」といったコマンド機能を柔軟に実装

この構成をベースにすれば、実際のプロジェクトでも 画面と音声認識の開始／停止を強く結びつけつつ、キーワード検出による任意アクション をシンプルに実現できます。

## おわりに
本記事で紹介した構成をベースにすれば、Flutter アプリでの音声認識機能を保守性高めで実装できるはずです。ぜひ一度お試しいただき、さらに自分のプロジェクトに合わせたカスタマイズを加えてみてください！