В статье хочу поделиться опытом создания легкого приложения, которое синхронно проговариваемой фразе выделяет текст внутри подсказки.
От клиента поступил заказ на разработку тренажера говорения на иностранных языках. Пользователю показывается фраза, он ее произносит, и по мере произношения (в случае правильного произношения) текстовый блок меняется по принципу караоке подсказки.
За основу приложения был взят готовый example с pub.dev https://pub.dev/packages/speech_to_text/example
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
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';
void main() => runApp(SpeechSampleApp());
class SpeechSampleApp extends StatefulWidget {
@override
_SpeechSampleAppState createState() => _SpeechSampleAppState();
}
/// An example that demonstrates the basic functionality of the
/// SpeechToText plugin for using the speech recognition capability
/// of the underlying platform.
class _SpeechSampleAppState extends State<SpeechSampleApp> {
bool _hasSpeech = false;
bool _logEvents = false;
double level = 0.0;
double minSoundLevel = 50000;
double maxSoundLevel = -50000;
String lastWords = '';
String lastError = '';
String lastStatus = '';
String _currentLocaleId = '';
List<LocaleName> _localeNames = [];
final SpeechToText speech = SpeechToText();
@override
void initState() {
super.initState();
}
/// This initializes SpeechToText. That only has to be done
/// once per application, though calling it again is harmless
/// it also does nothing. The UX of the sample app ensures that
/// it can only be called once.
Future<void> initSpeechState() async {
_logEvent('Initialize');
try {
var hasSpeech = await speech.initialize(
onError: errorListener,
onStatus: statusListener,
debugLogging: true,
);
if (hasSpeech) {
// Get the list of languages installed on the supporting platform so they
// can be displayed in the UI for selection by the user.
_localeNames = await speech.locales();
var systemLocale = await speech.systemLocale();
_currentLocaleId = systemLocale?.localeId ?? '';
}
if (!mounted) return;
setState(() {
_hasSpeech = hasSpeech;
});
} catch (e) {
setState(() {
lastError = 'Speech recognition failed: ${e.toString()}';
_hasSpeech = false;
});
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Speech to Text Example'),
),
body: Column(children: [
HeaderWidget(),
Container(
child: Column(
children: <Widget>[
InitSpeechWidget(_hasSpeech, initSpeechState),
SpeechControlWidget(_hasSpeech, speech.isListening,
startListening, stopListening, cancelListening),
SessionOptionsWidget(_currentLocaleId, _switchLang,
_localeNames, _logEvents, _switchLogging),
],
),
),
Expanded(
flex: 4,
child: RecognitionResultsWidget(lastWords: lastWords, level: level),
),
Expanded(
flex: 1,
child: ErrorWidget(lastError: lastError),
),
SpeechStatusWidget(speech: speech),
]),
),
);
}
// This is called each time the users wants to start a new speech
// recognition session
void startListening() {
_logEvent('start listening');
lastWords = '';
lastError = '';
// Note that `listenFor` is the maximum, not the minimun, on some
// recognition will be stopped before this value is reached.
// Similarly `pauseFor` is a maximum not a minimum and may be ignored
// on some devices.
speech.listen(
onResult: resultListener,
listenFor: Duration(seconds: 30),
pauseFor: Duration(seconds: 5),
partialResults: true,
localeId: _currentLocaleId,
onSoundLevelChange: soundLevelListener,
cancelOnError: true,
listenMode: ListenMode.confirmation);
setState(() {});
}
void stopListening() {
_logEvent('stop');
speech.stop();
setState(() {
level = 0.0;
});
}
void cancelListening() {
_logEvent('cancel');
speech.cancel();
setState(() {
level = 0.0;
});
}
/// This callback is invoked each time new recognition results are
/// available after `listen` is called.
void resultListener(SpeechRecognitionResult result) {
_logEvent(
'Result listener final: ${result.finalResult}, words: ${result.recognizedWords}');
setState(() {
lastWords = '${result.recognizedWords} - ${result.finalResult}';
});
}
void soundLevelListener(double level) {
minSoundLevel = min(minSoundLevel, level);
maxSoundLevel = max(maxSoundLevel, level);
// _logEvent('sound level $level: $minSoundLevel - $maxSoundLevel ');
setState(() {
this.level = level;
});
}
void errorListener(SpeechRecognitionError error) {
_logEvent(
'Received error status: $error, listening: ${speech.isListening}');
setState(() {
lastError = '${error.errorMsg} - ${error.permanent}';
});
}
void statusListener(String status) {
_logEvent(
'Received listener status: $status, listening: ${speech.isListening}');
setState(() {
lastStatus = '$status';
});
}
void _switchLang(selectedVal) {
setState(() {
_currentLocaleId = selectedVal;
});
print(selectedVal);
}
void _logEvent(String eventDescription) {
if (_logEvents) {
var eventTime = DateTime.now().toIso8601String();
print('$eventTime $eventDescription');
}
}
void _switchLogging(bool? val) {
setState(() {
_logEvents = val ?? false;
});
}
}
/// Displays the most recently recognized words and the sound level.
class RecognitionResultsWidget extends StatelessWidget {
const RecognitionResultsWidget({
Key? key,
required this.lastWords,
required this.level,
}) : super(key: key);
final String lastWords;
final double level;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Center(
child: Text(
'Recognized Words',
style: TextStyle(fontSize: 22.0),
),
),
Expanded(
child: Stack(
children: <Widget>[
Container(
color: Theme.of(context).selectedRowColor,
child: Center(
child: Text(
lastWords,
textAlign: TextAlign.center,
),
),
),
Positioned.fill(
bottom: 10,
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
blurRadius: .26,
spreadRadius: level * 1.5,
color: Colors.black.withOpacity(.05))
],
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(50)),
),
child: IconButton(
icon: Icon(Icons.mic),
onPressed: () => null,
),
),
),
),
],
),
),
],
);
}
}
class HeaderWidget extends StatelessWidget {
const HeaderWidget({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Text(
'Speech recognition available',
style: TextStyle(fontSize: 22.0),
),
);
}
}
/// Display the current error status from the speech
/// recognizer
class ErrorWidget extends StatelessWidget {
const ErrorWidget({
Key? key,
required this.lastError,
}) : super(key: key);
final String lastError;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Center(
child: Text(
'Error Status',
style: TextStyle(fontSize: 22.0),
),
),
Center(
child: Text(lastError),
),
],
);
}
}
/// Controls to start and stop speech recognition
class SpeechControlWidget extends StatelessWidget {
const SpeechControlWidget(this.hasSpeech, this.isListening,
this.startListening, this.stopListening, this.cancelListening,
{Key? key})
: super(key: key);
final bool hasSpeech;
final bool isListening;
final void Function() startListening;
final void Function() stopListening;
final void Function() cancelListening;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
TextButton(
onPressed: !hasSpeech || isListening ? null : startListening,
child: Text('Start'),
),
TextButton(
onPressed: isListening ? stopListening : null,
child: Text('Stop'),
),
TextButton(
onPressed: isListening ? cancelListening : null,
child: Text('Cancel'),
)
],
);
}
}
class SessionOptionsWidget extends StatelessWidget {
const SessionOptionsWidget(this.currentLocaleId, this.switchLang,
this.localeNames, this.logEvents, this.switchLogging,
{Key? key})
: super(key: key);
final String currentLocaleId;
final void Function(String?) switchLang;
final void Function(bool?) switchLogging;
final List<LocaleName> localeNames;
final bool logEvents;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: [
Text('Language: '),
DropdownButton<String>(
onChanged: (selectedVal) => switchLang(selectedVal),
value: currentLocaleId,
items: localeNames
.map(
(localeName) => DropdownMenuItem(
value: localeName.localeId,
child: Text(localeName.name),
),
)
.toList(),
),
],
),
Row(
children: [
Text('Log events: '),
Checkbox(
value: logEvents,
onChanged: switchLogging,
),
],
)
],
),
);
}
}
class InitSpeechWidget extends StatelessWidget {
const InitSpeechWidget(this.hasSpeech, this.initSpeechState, {Key? key})
: super(key: key);
final bool hasSpeech;
final Future<void> Function() initSpeechState;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
TextButton(
onPressed: hasSpeech ? null : initSpeechState,
child: Text('Initialize'),
),
],
);
}
}
/// Display the current status of the listener
class SpeechStatusWidget extends StatelessWidget {
const SpeechStatusWidget({
Key? key,
required this.speech,
}) : super(key: key);
final SpeechToText speech;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(vertical: 20),
color: Theme.of(context).backgroundColor,
child: Center(
child: speech.isListening
? Text(
"I'm listening...",
style: TextStyle(fontWeight: FontWeight.bold),
)
: Text(
'Not listening',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
Предварительно был отработан другой пакет (https://pub.dev/packages/speech_to_text/versions), но он, хоть и использовал средства iOS speech работал значительно медленнее, не позволял настраивать интервал проговаривания фразы и паузы.
Заказчик оговаривал необходимость создания приложения именно для iOS, но Flutter был выбран как задел на будущее т.к. имелась возможность легко развернуть выше указанный пример под Android.
Для разбора произносимой фразы на помеченные и не помеченные области была написана простая функция, возвращающая RichText:
List<TextSpan> highlightOccurrences(String source, String query) {
if (query == null || query.isEmpty || !source.toLowerCase().contains(query.toLowerCase())) {
//startSpech = false;
return [ TextSpan(text: source) ];
}
final matches = query.toLowerCase().allMatches(source.toLowerCase());
int lastMatchEnd = 0;
final List<TextSpan> children = [];
for (var i = 0; i < matches.length; i++) {
final match = matches.elementAt(i);
if (match.start != lastMatchEnd) {
children.add(TextSpan(
text: source.substring(lastMatchEnd, match.start),
));
}
children.add(TextSpan(
text: source.substring(match.start, match.end),
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.black),
));
if (i == matches.length - 1 && match.end != source.length) {
children.add(TextSpan(
text: source.substring(match.end, source.length),
));
}
lastMatchEnd = match.end;
}
return children;
}
Все заработало, но потребовались некторые действия "напильником".
перед сравнением распознанной и эталонной фразы настоятельно рекомендую перевести все в нижний регистр, удалить пробелы, любые спец-символы и знаки препинания, а так же скрытые переносы и табы.
Эффективнее работает сравнение не строки со строкой, а массива слов с массивом слов. Так появляется возможность обходить не любимые speech_ем ямки при плохом произношении и фразах типа Пушкинских слогов.
Мороз и солнце; день чудесный!
Еще ты дремлешь, друг прелестный —
Пора, красавица, проснись:
Открой сомкнуты негой взоры
Навстречу северной Авроры,
Звездою севера явись!лучше распознавалось подогнанным под speech
Мороз и солнце день чудесный Ещё ты дремлешь друг прелестный Пора красавица проснись Открой сомкнутые негой взоры Навстречу северной Авроры Звездою севера явись
Остались и не доработки, ради которых я пишу эту статью в надежде на советы.
Listener работает крайне медленно и при быстром говорении не получается синхронно распознавать и размечать такс, появляется сильный лаг
Встроенный speech работает крайне плохо и с большими оговорками. Раз старается писать как 1, "Пушкинский слог" пытается преобразовать в мирской и т.д.
Клиент хотел бы использовать CMU Sphinx https://cmusphinx.github.io/wiki/download/, но он не портируется в iOS.
Буду рад любым советам и замечаниям! Просьба сильно не булить, это моя первая статья на Хабр :-)