【Flutter】speech_to_textで音声入力し文字起こし

当サイトで紹介する商品・サービス等の外部リンクは、アフィリエイト広告を含む場合があります。
スポンサーリンク
本記事を読むと以下の実行ができます

話しかけた内容がそのまま画面に文字で出続け、音声認識が止まっても自動で聞き取り直す音声入力画面を、speech_to_text で作れます。

Flutterで英会話アプリや音声メモアプリを作るとき話した言葉を文字に入力が第一歩です。

Flutterではspeech_to_textというパッケージを使うと、スマホやブラウザに入っている音声認識の機能を、短いコードで呼び出せます。ただし、そのままだと無音が続いたときなどに認識が止まってしまいます。

本記事では、話している途中の文字を表示しながら、止まったら自動で聞き取り直す画面を作ります。パッケージの呼び出しは別のクラスに分け、画面側のコードをすっきりさせます。

本記事は次の人におすすめ
  • Flutter で音声入力を作りたい人
  • speech_to_text の基本的な流れ(初期化、聞き取り開始、結果の受け取り)を知りたい人
  • 話している途中の文字を、画面にリアルタイムで表示したい人
  • 音声認識が途中で止まる問題への対処を知りたい人
スポンサーリンク

speech_to_text(音声認識パッケージ)

speech_to_text は、端末に入っている音声認識の機能を Flutter から使うためのパッケージです。Android、iOS、Web に対応しています。

注意したいのは、このパッケージが想定している使い方です。公式の説明では、想定しているのはコマンドや短いフレーズで、長い会話を途切れなく文字にしたり、ずっと聞き続けたりする使い方は想定外とされています。

そのため、今回のように「開いている間ずっと聞き取る」画面を作るときは、止まったら聞き取り直す仕組みを自分で用意します。

インストール

pubspec.yaml の dependencies に追加します。この記事のコードは ^7.4.0 で作っています。

dependencies:
  speech_to_text: ^7.4.0

事前設定(マイクの許可)

マイクを使うため、プラットフォームごとに設定が必要です。設定の内容は公式ページの記載にもとづきます。

プラットフォーム設定
AndroidAndroidManifest.xml に RECORD_AUDIO と INTERNET などの権限を追加する。ターゲット SDK が 30 以上なら、android.speech.RecognitionService を指定した queries も追加する
iOSInfo.plist に NSSpeechRecognitionUsageDescription と NSMicrophoneUsageDescription を追加する
Web設定は不要。ただし対応しているブラウザは一部のみ

参考:speech_to_text 公式ページ

話した内容を文字にする音声入力画面

ここまでの内容をもとに、次の動きをする画面を作ります。

  • 画面を開くと、すぐ聞き取りを始める
  • 話している途中の文字も、確定した文字も、画面に出し続ける
  • 右上のマイクのアイコンが、聞き取り中は赤、停止中は灰色になる
  • 認識が止まったら、少し待って自動で聞き取り直す

ソースコード

  • speech_service.dart

speech_to_text を包むクラス。パッケージの細かい呼び出しをここに集める

import 'package:speech_to_text/speech_to_text.dart' as stt;

class SpeechService {
  final stt.SpeechToText _speech = stt.SpeechToText();

  Future<bool> initialize({
    required void Function(bool isListening) onStatusChange,
    required void Function(String message) onError,
  }) {
    return _speech.initialize(
      onStatus: (status) => onStatusChange(status == 'listening'),
      onError: (error) => onError(error.errorMsg),
    );
  }

  /// 話した内容を、認識できた分だけ onResult に渡し続ける。
  /// 一区切り話し終えると isFinal が true になる。
  Future<void> listen({
    required void Function(String recognizedWords, bool isFinal) onResult,
    String? localeId,
  }) {
    return _speech.listen(
      onResult: (result) => onResult(result.recognizedWords, result.finalResult),
      listenOptions: stt.SpeechListenOptions(
        localeId: localeId,
        partialResults: true,
      ),
    );
  }

  Future<void> stop() => _speech.stop();
}
  • voice_input_page.dart

画面。表示する文字と状態だけを持つ

import 'dart:async';
import 'package:flutter/material.dart';
import '../services/speech_service.dart';

class VoiceInputPage extends StatefulWidget {
  const VoiceInputPage({super.key, required this.title});

  final String title;

  @override
  State<VoiceInputPage> createState() => _VoiceInputPageState();
}

class _VoiceInputPageState extends State<VoiceInputPage> {
  final SpeechService _speech = SpeechService();

  bool _speechEnabled = false;
  bool _isListening = false;
  Timer? _restartTimer;

  String _committedText = ''; // 話し終えて確定した分
  String _partialText = ''; // いま話している途中の分
  String _statusMessage = '';

  @override
  void initState() {
    super.initState();
    _initSpeech();
  }

  @override
  void dispose() {
    _restartTimer?.cancel();
    _speech.stop();
    super.dispose();
  }

  Future<void> _initSpeech() async {
    final available = await _speech.initialize(
      onStatusChange: _handleStatus,
      onError: _handleError,
    );
    if (!mounted) return;
    setState(() {
      _speechEnabled = available;
      if (!available) _statusMessage = 'この端末では音声認識を利用できません';
    });
    if (available) _listen();
  }

  Future<void> _listen() async {
    if (!mounted || !_speechEnabled || _isListening) return;
    await _speech.listen(onResult: _handleResult);
  }

  void _handleResult(String words, bool isFinal) {
    setState(() {
      if (isFinal) {
        _committedText = '$_committedText $words'.trim();
        _partialText = '';
      } else {
        _partialText = words;
      }
    });
  }

  // 認識が止まったら、少し待って聞き取り直す。
  void _handleStatus(bool listening) {
    if (!mounted) return;
    setState(() => _isListening = listening);
    if (!listening && _speechEnabled) {
      _restartTimer?.cancel();
      _restartTimer = Timer(const Duration(milliseconds: 300), _listen);
    }
  }

  void _handleError(String message) {
    if (!mounted) return;
    setState(() {
      _isListening = false;
      _statusMessage = '音声認識エラー: $message';
      // マイクの許可がないときは、やり直しても同じなので止める。
      if (message.contains('permission')) _speechEnabled = false;
    });
  }

  String get _displayText => '$_committedText $_partialText'.trim();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
        actions: [
          Padding(
            padding: const EdgeInsets.only(right: 16),
            child: Icon(
              _isListening ? Icons.mic : Icons.mic_off,
              color: _isListening ? Colors.red : Colors.grey,
            ),
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Expanded(
              child: SingleChildScrollView(
                child: Text(
                  _displayText.isEmpty ? '話しかけてください' : _displayText,
                  style: Theme.of(context).textTheme.headlineSmall,
                ),
              ),
            ),
            Text(_statusMessage),
          ],
        ),
      ),
    );
  }
}
  • main.dart
import 'package:flutter/material.dart';

import 'pages/voice_input_page.dart';

void main() {
  runApp(
    const MaterialApp(home: VoiceInputPage(title: '音声入力')),
  );
}

解説

音声認識の呼び出しはSpeechServiceに集め、画面のVoiceInputPageは「いま何が表示されているか」だけを持ちます。こうしておくと、あとで別のパッケージに替えるときも、直すのは SpeechService だけで済みます。

画面が持つ状態は次の4つです。

変数意味
_speechEnabledこの端末で音声認識が使えるか
_isListeningいま聞き取り中か(アイコンの色に使う)
_committedText話し終えて確定した文字
_partialTextいま話している途中の文字

各関数の機能

initialize:音声認識を使える状態にする

initializeは、音声認識を使える状態にする最初の処理です。使えるときは true、使えないときは false が返ります。公式の説明では、initializeは一度だけ呼ぶ想定で、2回目以降の呼び出しは無視されます。そのため、状態の変化とエラーを受け取る関数は、最初の呼び出しで渡します。

SpeechService.initialize では、受け取った状態が listening のときだけ true として画面に伝えます。

listen:聞き取りを始める

listen で聞き取りを始めます。ポイントは partialResults: true です。これを指定すると、話し終わるのを待たず、話している途中の文字が結果に何度も届きます。話しながら文字が増えていく動きは、この設定のおかげです。

localeIdを省略すると、端末の標準の言語で聞き取ります。言語を指定したいときは、locales() で取れる言語の一覧にある ID を渡します。

_handleResult:途中の文字と確定した文字を分ける

結果は isFinal で2つに分かれます。

  • isFinal が false:話している途中。_partialText を上書きする
  • isFinal が true:一区切り話し終えた。_committedText の後ろに足し、_partialText を空にする

画面には、2つをつなげた _displayText を出します。

_handleStatus:止まったら聞き取り直す

音声認識は、無音が続くなどで自分から止まります。公式の説明のとおり、ずっと聞き続ける使い方は想定されていないためです。

そこで、状態が「聞き取り中」から変わったら、Timer で 300 ミリ秒待ってから _listen を呼び直します。止まった直後には呼び直さず、少し間を置いてから再開しています。

_handleError:許可がないときは止める

エラーの文字にpermission が含まれていたら、マイクの許可がありません。何度やり直しても同じ結果になるので_speechEnabled を false にして再開を止めます。

dispose:後片付け

画面を閉じるときに、再開用のTimerを止め、聞き取りも止めます。

実装

  1. Flutter のプロジェクトを作り、speech_to_text を pubspec.yaml に追加して flutter pub get を実行する
  2. 先ほどの「事前設定」を済ませる
  3. 上のコードを lib/ に置き、実行する
  4. 初回はマイクの許可を求められるので、許可する

画面を開くと聞き取りが始まり、アイコンが赤になります。話しかけると、途中の文字が出て、話し終えると確定します。

エラーと解決策・対策

以下では、想定しうるエラーの解決方法を紹介します。

「この端末では音声認識を利用できません」と出る

initialize が false を返した状態です。音声認識が使えない環境で起きます。公式の説明では、ビルドはできても音声認識に対応していないプラットフォームがあり、その場合は initialize が false を返します。

先に述べた事前設定が済んでいるか、対応しているブラウザや端末かを確認してください。

「音声認識エラー」に permission と出る

マイクの許可がありません。端末やブラウザの設定で、アプリにマイクの使用を許可してください。このコードでは、許可がないと分かった時点で聞き取りのやり直しを止めます。

言語が思った言語にならない

localeId を省略すると、端末の標準の言語で聞き取ります。ほかの言語で話したいときは、locales() で取れる言語の ID を listen の localeId に渡してください。

まとめ

speech_to_text の流れは、initialize で準備し、listen で聞き取り、結果を受け取る、の3つです。

  • partialResults: true で、話している途中の文字を出せる
  • isFinal で、途中の文字と確定した文字を分けて持つ
  • 音声認識は自分から止まるので、状態の変化を見て聞き取り直す
  • パッケージの呼び出しは SpeechService に分け、画面は状態だけを持つ
タイトルとURLをコピーしました