Flutter iOS 音声メモアプリ開発(2) 音声認識
前回の記事で、メモアプリを実現しましたので、今回は入力を音声認識に変更していきます。
Flutterで利用可能な音声認識もクラウド版、端末版とありますが、今回は端末の認識機能を使うspeech_to_textパッケージを使います。
前回の記事はこちら。
音声認識 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();
}
}
読みにくいコードになってしまいました。この後、なんとか整理したいと思います。