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

前回の記事で、メモアプリを実現しましたので、今回は入力を音声認識に変更していきます。

Flutterで利用可能な音声認識もクラウド版、端末版とありますが、今回は端末の認識機能を使うspeech_to_textパッケージを使います。

前回の記事はこちら。

Flutter iOS 音声メモアプリ開発(1)永続的データの保存

Flutterで開発したアプリケーションの公開に挑戦してみたいと思います。 Flutter はAndroidとiOSのどちらも開発可能ではありますが、個人的にiPhoneユーザでありますので…

音声認識 speech_to_text

speech_to_textに関しては、以前記事化しておりますが、もう一度おさらいしておきます。

まずはパッケージのインストールです。

flutter pub add speech_to_text

iOSの場合にはマイクの使用時、音声認識の利用時には、以下をinfo.plistに追加しておきます。

<key>NSSpeechRecognitionUsageDescription</key>
<string>”アプリが音声認識を使用する理由”</string>
<key>NSMicrophoneUsageDescription</key>
<string>”アプリがマイクにアクセスする理由”</string>

また、日本語で認識できない場合には、以下もinfo.plistに追加します。

<key>CFBundleLocalizations</key>
	<array>
		<string>ja</string>
	</array>

以下、音声認識の基本コードです。

  //パッケージのインポート
  import 'package:speech_to_text/speech_recognition_error.dart';
  import 'package:speech_to_text/speech_recognition_result.dart';
  import 'package:speech_to_text/speech_to_text.dart' as stt;  

 // 音声認識結果の変数初期化
  String lastWords = "";
  String lastError = 'last-error';
  String lastStatus = 'last-status';
 // 認識中フラグ
  bool isUserSpeaking = false;

  stt.SpeechToText speech = stt.SpeechToText();

 //認識開始
  Future<void> _speak() async {
    lastWords = "";
    bool available = await speech.initialize(
        onError: errorListener, onStatus: statusListener);
    if (available) {
      speech.listen(onResult: resultListener);
      setState(() {
        isUserSpeaking = true;
      });
    } else {
      //print("The user has denied the use of speech recognition.");
    }
  }

 //認識終了
  Future<void> _stop() async {
    speech.cancel();
    setState(() {
      isUserSpeaking = false;
    });
  }

  void resultListener(SpeechRecognitionResult result) {
    setState(() {
      lastWords = result.recognizedWords;
    });
  }

  void errorListener(SpeechRecognitionError error) {
    setState(() {
      lastError = '${error.errorMsg} - ${error.permanent}';
    });
  }

  void statusListener(String status) {
    setState(() {
      lastStatus = status;
    });
  }

音声認識開始、および音声認識終了イベント時に、_speak(),_stop()をそれぞれ呼び出します。認識結果は lastWordsに格納されます。

音声認識メモアプリ ver0.1

前回のメモアプリに音声認識を組み込みます。

入力用のテキストTextFiledの代わりにText Widgetとし、音声認識結果のlastWordsを表示させます。

認識中か否かをisUserSpeakingフラグで判断して、IconButtonをIcon .micと、Icon .sendとで切り替えています。また、isUserSpeakingがFalseの場合には、_speak()を呼び出し、Trueの場合には、_stop()の後に、_addDataTextでデータの登録をしています。

画面はこんな感じにしました。

コードの全体はこちらになります。

import 'dart:async';
import 'card.dart';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:localstore/localstore.dart';

import 'package:http/http.dart' as http;
import 'package:google_fonts/google_fonts.dart';
import 'dart:convert';

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

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Voice Memo',
      theme: ThemeData(
        textTheme: GoogleFonts.kosugiMaruTextTheme(Theme.of(context).textTheme),
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Voice Memo Home Page'),
    );
  }
}

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

  final String? title;

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

class _MyHomePageState extends State<MyHomePage> {
  final _db = Localstore.instance;
  late var _items = <String, Todo>{};
  StreamSubscription<Map<String, dynamic>>? _subscription;

  final TextEditingController _textController = TextEditingController();
  final ScrollController _scrollController = ScrollController();

  final String positive = '😀';
  final String neutral = '🙂';
  final String negative = '😢';
  final String other = '😐';

  String lastWords = "";
  String lastError = 'last-error';
  String lastStatus = 'last-status';

  bool isUserSpeaking = false;

  stt.SpeechToText speech = stt.SpeechToText();

  Future<void> _speak() async {
    lastWords = "";
    bool available = await speech.initialize(
        onError: errorListener, onStatus: statusListener);
    if (available) {
      speech.listen(onResult: resultListener);
      setState(() {
        isUserSpeaking = true;
      });
    } else {
      //print("The user has denied the use of speech recognition.");
    }
  }

  Future<void> _stop() async {
    speech.cancel();
    setState(() {
      isUserSpeaking = false;
    });
  }

  void resultListener(SpeechRecognitionResult result) {
    setState(() {
      lastWords = result.recognizedWords;
    });
  }

  void errorListener(SpeechRecognitionError error) {
    setState(() {
      lastError = '${error.errorMsg} - ${error.permanent}';
    });
  }

  void statusListener(String status) {
    setState(() {
      lastStatus = status;
    });
  }

  @override
  void initState() {
    _subscription = _db.collection('todos').stream.listen((event) {
      setState(() {
        final item = Todo.fromMap(event);
        _items.putIfAbsent(item.id, () => item);
      });
    });
    if (kIsWeb) _db.collection('todos').stream.asBroadcastStream();
    super.initState();
  }

  Future<void> _addDataText(String inputText) async {
    if (inputText != '') {
      final sentiment = await _Sentiment(inputText);
      final id = Localstore.instance.collection('todos').doc().id;
      final now = DateTime.now();

      final item = Todo(
        id: id,
        title: inputText,
        time: now,
        sentiment: sentiment,
        //imageFile: '',
        done: false,
      );
      item.save();
      _items.putIfAbsent(item.id, () => item);
      _scrollLast();
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => primaryFocus?.unfocus(),
      //onTap: () {FocusScope.of(context).unfocus();},
      child: Scaffold(
        appBar: AppBar(
          title: Text(widget.title!),
          actions: [
            IconButton(
              onPressed: () {
                setState(() {
                  _db.collection('todos').delete();
                  _items.clear();
                });
              },
              icon: const Icon(Icons.delete_outlined),
            )
          ],
        ),
        body: Container(
          padding: const EdgeInsets.all(10),
          child: Column(children: [
            Expanded(
              child: ListView.builder(
                //reverse: true,
                controller: _scrollController,
                itemCount: _items.keys.length,
                itemBuilder: (context, index) {
                  final key = _items.keys.elementAt(index);
                  final item = _items[key]!;

                  return GestureDetector(
                    onTap: () => Navigator.push(
                        context,
                        MaterialPageRoute(
                            builder: (context) => CardPage(
                                  card_item: item,
                                ))),
                    child: Card(
                      child: Column(children: <Widget>[
                        ListTile(
                            title: Text(item.title),
                            subtitle: Text(item.time.toString()),
                            trailing: Text(item.sentiment,
                                style:
                                    Theme.of(context).textTheme.headlineSmall),
                            leading: IconButton(
                                icon: const Icon(Icons.delete),
                                onPressed: () async {
                                  var ans = await _showDeleteDialog();
                                  
                                  if (ans == 'OK') {
                                    setState(() {
                                      item.delete();
                                      _items.remove(item.id);
                                    });
                                  }
                                })),
                      ]),
                    ),
                  );
                },
              ),
            ),
            const SizedBox(
              height: 5,
            ),
            Container(
              color: Colors.blue,
              padding: const EdgeInsets.all(5),
              margin: const EdgeInsets.symmetric(horizontal: 8.0),
              child: Row(
                children: <Widget>[
                  Expanded(
                    child: Container(
                        height: 50,
                        padding: const EdgeInsets.all(5),
                        child: Text(
                          lastWords,
                          style: const TextStyle(
                              color: Colors.white, fontWeight: FontWeight.bold),
                        )),
                  ),
                  Container(
                    margin: const EdgeInsets.symmetric(horizontal: 4.0),
                    child: IconButton(
                        iconSize: 24,
                        color: Colors.white,
                        icon: isUserSpeaking
                            ? const Icon(
                                Icons.send,
                              )
                            : const Icon(
                                Icons.mic,
                              ),
                        onPressed: () {
                          if (isUserSpeaking) {
                            _stop();
                            if (lastWords.isEmpty) {
                              //print("empty message");
                            } else {
                              setState(() {
                                _addDataText(lastWords);
                              });
                              lastWords = '';
                            }
                          } else {
                            _speak();
                          }
                        }),
                  )
                ],
              ),
            ),
          ]),
        ),
      ),
    );
  }

  void _scrollLast() async {
    await Future.delayed(const Duration(milliseconds: 100));
    _textController.text = '';
    _scrollController.jumpTo(
      _scrollController.position.maxScrollExtent,
    );
  }

  @override
  void dispose() {
    if (_subscription != null) _subscription?.cancel();
    super.dispose();
  }

  Future<String> _showDeleteDialog() async {
    var result = await showDialog(
      context: context,
      builder: (_) {
        return AlertDialog(
          title: const Text('DELETE'),
          content: const Text('削除しますよろしいですか?'),
          actions: [
            TextButton(
              child: const Text('OK'),
              onPressed: () => Navigator.pop(context, 'OK'),
            ),
            TextButton(
              child: const Text('Cancel'),
              onPressed: () => Navigator.pop(context, 'Cancel'),
            ),
          ],
        );
      },
    );
    return result;
  }

  Future<String> _Sentiment(query) async {
    String url ="<Your url>"
    String body = json.encode({
      "kind": "SentimentAnalysis",
      "parameters": {"modelVersion": "latest"},
      "analysisInput": {
        "documents": [
          {"id": "1", "language": "ja", "text": query}
        ]
      }
    });
    Map<String, String> headers = {
      'Ocp-Apim-Subscription-Key': '<Your Key>',
      'Content-Type': 'application/json'
    };

    http.Response resp =
        await http.post(Uri.parse(url), headers: headers, body: body);

    var decode = json.decode(resp.body);
    try {
      final String sentiment = decode['results']['documents'][0]['sentiment'];

      if (sentiment == 'positive') {
        return positive;
      } else if (sentiment == 'neutral') {
        return neutral;
      } else if (sentiment == 'negative') {
        return negative;
      } else {
        return other;
      }
    } catch (e) {
      return other;
    }
  }
}

/// Data Model
class Todo {
  final String id;
  String title;
  DateTime time;
  String sentiment;
  bool done;

  Todo({
    required this.id,
    required this.title,
    required this.time,
    required this.sentiment,
    required this.done,
  });

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'title': title,
      'time': time.millisecondsSinceEpoch,
      'sentiment': sentiment,
      'done': done,
    };
  }

  factory Todo.fromMap(Map<String, dynamic> map) {
    return Todo(
      id: map['id'],
      title: map['title'],
      time: DateTime.fromMillisecondsSinceEpoch(map['time']),
      sentiment: map['sentiment'],
      done: map['done'],
    );
  }
}

extension ExtTodo on Todo {
  Future save() async {
    final _db = Localstore.instance;
    return _db.collection('todos').doc(id).set(toMap());
  }

  Future delete() async {
    final _db = Localstore.instance;
    return _db.collection('todos').doc(id).delete();
  }
}

読みにくいコードになってしまいました。この後、なんとか整理したいと思います。