Flutter iOS 音声メモアプリ開発(6) 音声認識用TextField

Flutterの音声認識(speech_to_text)については、何度も利用してきました。今までは、音声認識結果をText Widgetに表示していましたが、これをTextFieldに変更したいと思います。これは、認識結果が少し間違っていた場合など、直ぐに修正できるようにするためです。

合わせて、再利用が可能なように、クラス化してみたいと思います。

事前準備

まずは音声認識用のパッケージ(speech_to_text)のインストールをします。

また、iOSの場合には、info.plistファイルの修正も忘れない様にします。

音声認識の事前準備は、以下の記事を参考にしてください。

Flutter iOS 音声メモアプリ開発(2) 音声認識

前回の記事で、メモアプリを実現しましたので、今回は入力を音声認識に変更していきます。 Flutterで利用可能な音声認識もクラウド版、端末版とありますが、今回は端末の…

TextFieldのイベント処理追加

TextFieldは入力値の値が変わったとき、onChangeイベントを使って入力値を取得することができます。ただし、これはユーザがキーボードから入力した場合にのみ発生するイベントであって、音声認識結果でTextFieldの値を変更してもonChangeイベントは発生しません。

そこで、TextFieldにcontrollerで設定したTextEditingControllerオブジェクトをListenし、変更を検知する必要があります。

以下は、TextEditingControllerを使用して、音声入力された時のイベントを取得する方法の例です。

final TextEditingController _controller = TextEditingController();

@override
void initState() {
  super.initState();
  _controller.addListener(_onTextChanged);
}

void _onTextChanged() {
  String text = _controller.text;
  // テキストが変更されたら、ここで行いたい処理を行う
}

@override
Widget build(BuildContext context) {
  return TextField(
    controller: _controller,
  );
}

これで、音声認識結果を_controller.textに代入したときにでも、_onTextChanged()関数が呼び出されます。

マイクボタンの追加

TextFiledにマイクボタンを追加して、ボタンのトリガーで、音声認識のON/OFFを制御します。

 @override
  Widget build(BuildContext context) {
    return Column(children: [
      CupertinoTextField(
        placeholder: 'マイクアイコンを押して話しかけてください...',
        controller: _controller,
        suffix: GestureDetector(
          onTap: () {
            if (_isListening) {
              _stopListening();
            } else {
              _controller.text = '';
              _startListening();
            }
          },
          child: Icon(
              _isListening ? CupertinoIcons.paperplane : CupertinoIcons.mic),
        ),
      ),
    ]);
  }

suffixによって、TextFiledの後にWidgetを付加します。今回はIconを追加しています。

_isListeningが音声認識中か否かを持つフラグです。Iconをタップした時、認識中であれば音声認識をストップし、認識中でなければ、TextFiledの値を初期化して認識を開始します。

また、認識中か否かで、"マイク"と"紙飛行機"のどちらのIconを表示するかを変えています。

コールバック関数

音声認識の結果を呼び出し元に渡すためには、コールバック関数を定義します。

一般的には、Widget間で値を引き渡す方法は、以下の様になります。

class ParentWidget extends StatefulWidget {
  @override
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  String _text = '';

  @override
  Widget build(BuildContext context) {
    return ChildWidget(
      callback: (value) {
        setState(() {
          _text = value;
        });
      },
    );
  }
}

class ChildWidget extends StatelessWidget {
  final Function(String) callback;

  const ChildWidget({Key key, this.callback}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return TextField(
      onChanged: (value) {
        callback(value);
      },
    );
  }
}

この例では、ChildWidgetのonChangedイベント発生時にコールバック関数(callback(value))を読んでいます。しかし、先述の通り、キーボードからの入力でなければonChangedは発生しません。今回の場合、Listenerの中でコールバック関数を呼ぶこととなります。

  @override
  void initState() {
    super.initState();
       //リスナーの定義と、コールバック
    _controller.addListener(() {         
      String text = _controller.text;
      widget.onTextChanged(text);
    });
  }

サンプルコード

音声入力用TextFieldの全体のコードは、以下の様になります。


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

class VoiceTextField extends StatefulWidget {
  final void Function(String) onTextChanged;     //  コールバック関数の定義

  const VoiceTextField({super.key, required this.onTextChanged});  //  requiredを追加

  @override
  _VoiceTextFieldState createState() => _VoiceTextFieldState();
}

class _VoiceTextFieldState extends State<VoiceTextField> {
  final _speech = stt.SpeechToText();
  bool _isListening = false;
  final TextEditingController _controller = TextEditingController();
  String text = '';

  @override
  void initState() {
    super.initState();
       //リスナーの定義と、コールバック
    _controller.addListener(() {         
      String text = _controller.text;
      widget.onTextChanged(text);
    });
  }

    //音声認識スタート
  void _startListening() async {
    if (!_isListening) {
      bool available = await _speech.initialize();
      if (available) {
        setState(() => _isListening = true);
        _speech.listen(onResult: (result) {
          setState(() {
            _controller.text = result.recognizedWords;
          });
        });
      } else {
        setState(() {
          _isListening = false;
        });
      }
    }
  }

  //音声認識ストップ
  void _stopListening() {
    if (_isListening) {
      setState(() => _isListening = false);
      _speech.stop();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      CupertinoTextField(
        placeholder: 'マイクアイコンを押して話しかけてください...',
        controller: _controller,
        suffix: GestureDetector(
          onTap: () {
            if (_isListening) {
              _stopListening();
            } else {
              _controller.text = '';
              _startListening();
            }
          },
          child: Icon(
              _isListening ? CupertinoIcons.paperplane : CupertinoIcons.mic),
        ),
      ),
    ]);
  }

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

このクラスの使用例(main .dart)は以下の様になります。

import 'package:flutter/material.dart';
import 'voice_textfield.dart';    

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Voice textField',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

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

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String voiceInput = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            VoiceTextField(onTextChanged: (text) {  //  音声認識用TextField
              setState(() {
                voiceInput = text;
              });
            }),
            const SizedBox(height: 20),
            Text(
              '認識結果:$voiceInput',
              style: Theme.of(context).textTheme.titleMedium,
            ),
          ],
        ),
      ),
    );
  }
}

実行結果は、以下の様な画面になります。

これで、VoiceTextFieldの値が変わるたびに、MyHomePageのvoiceInput変数が更新されることとなります。