refactor(app): the device's speech recognition moves from Learn into halkyon_app, with dictation into a text
Learn's `SpeechInput` and `DeviceSpeechInput` (ADR 0021, D67) are halkyon_app's now, and its `FakeSpeechInput` is in `testing.dart`, so Meditate's dream journal (ADR 0038, D15) listens through the same code. Learn imports them from there and behaves as before: its composer and its tests are unchanged but for the imports, and `DeviceSpeechInput()` asks for what it asked before. `DeviceSpeechInput` takes three options, off by default: - `preferOnDevice`: on Android, recognition on the device where it can (Android 12 on), else offline; should that fail before a word is heard (not a pause), it listens as Android otherwise does from then on. - `pauseFor`: how long a pause may last before the recogniser stops. - `listenFor`: how long one listen may last at most. `Dictation` writes what is heard where the cursor stands (`DictationInsertion`: in place of a selection, set apart by a space), shows the words so far, and listens again after each pause while it is on, stopping by itself after three listens running heard nothing. speech_to_text moves from Learn's pubspec to halkyon_app's, the lock unchanged. Todo, Train and Meditate link the plugin now; their generated plugin registrants (macOS, Windows) say so. An app declares the microphone itself, and Todo and Train declare none, so nothing there can listen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
86c9f4522a
commit
603218ef96
7 changed files with 405 additions and 16 deletions
|
|
@ -176,6 +176,8 @@ export 'src/simplelogin/widget/simplelogin_section.dart';
|
|||
export 'src/simplelogin/widget/simplelogin_sheet.dart';
|
||||
export 'src/simplelogin/widget/simplelogin_standing_scope.dart';
|
||||
export 'src/simplelogin/widget/simplelogin_texts.dart';
|
||||
export 'src/speech/dictation.dart';
|
||||
export 'src/speech/speech_input.dart';
|
||||
export 'src/sync/app_compatibility.dart';
|
||||
export 'src/sync/bloc/sync_status_bloc.dart';
|
||||
export 'src/sync/pending_changes_store.dart';
|
||||
|
|
|
|||
159
packages/halkyon_app/lib/src/speech/dictation.dart
Normal file
159
packages/halkyon_app/lib/src/speech/dictation.dart
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:halkyon_app/src/speech/speech_input.dart';
|
||||
|
||||
/// Where the words of one listen go in a text (Halkyon Meditate's ADR 0038,
|
||||
/// D15): in place of what is selected, or where the cursor stands (at the
|
||||
/// end where the field has none yet), set apart from the text on either
|
||||
/// side by a space where it has none there.
|
||||
final class DictationInsertion._(
|
||||
final String _before,
|
||||
final String _after,
|
||||
) {
|
||||
/// Where words go in [value], as it stands when listening begins.
|
||||
factory at(TextEditingValue value) {
|
||||
final text = value.text;
|
||||
final selection = value.selection;
|
||||
final valid =
|
||||
selection.isValid &&
|
||||
selection.start >= 0 &&
|
||||
selection.end <= text.length;
|
||||
return DictationInsertion._(
|
||||
valid ? text.substring(0, selection.start) : text,
|
||||
valid ? text.substring(selection.end) : '',
|
||||
);
|
||||
}
|
||||
|
||||
/// The text with [words] put in, the cursor after them; as it was where
|
||||
/// [words] is empty.
|
||||
TextEditingValue withWords(String words) {
|
||||
if (words.isEmpty) {
|
||||
final text = '$_before$_after';
|
||||
return TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: _before.length),
|
||||
);
|
||||
}
|
||||
final lead = _before.isEmpty || _endsInSpace(_before) ? '' : ' ';
|
||||
final trail = _after.isEmpty || _startsInSpace(_after) ? '' : ' ';
|
||||
final head = '$_before$lead$words';
|
||||
return TextEditingValue(
|
||||
text: '$head$trail$_after',
|
||||
selection: TextSelection.collapsed(offset: head.length),
|
||||
);
|
||||
}
|
||||
|
||||
static bool _endsInSpace(String text) =>
|
||||
text.trimRight().length != text.length;
|
||||
|
||||
static bool _startsInSpace(String text) =>
|
||||
text.trimLeft().length != text.length;
|
||||
}
|
||||
|
||||
/// Dictation into [_text] (Halkyon Meditate's ADR 0038, D15): what is said
|
||||
/// goes where the cursor stands ([DictationInsertion]) as it is heard, and
|
||||
/// [heard] holds the listen's words so far, to show. A recogniser stops
|
||||
/// after a pause, so while the person leaves it on, it listens again at
|
||||
/// once, the next words after the last, until it has heard nothing
|
||||
/// [silencesBeforeStopping] times running or the person stops it.
|
||||
class Dictation(
|
||||
final SpeechInput _speech,
|
||||
final TextEditingController _text,
|
||||
) extends ChangeNotifier {
|
||||
/// How many listens running may hear nothing before it stops by itself:
|
||||
/// a dream remembered may need a moment, a phone left on the table not.
|
||||
static const silencesBeforeStopping = 3;
|
||||
|
||||
var _on = false;
|
||||
var _heard = '';
|
||||
var _silences = 0;
|
||||
String? _language;
|
||||
|
||||
/// Counts the listens, so a listen ended late tells nothing.
|
||||
var _listen = 0;
|
||||
|
||||
/// Whether this kind of device recognises speech at all.
|
||||
bool get supported => _speech.supported;
|
||||
|
||||
/// Whether it is on: listening, or about to again after a pause.
|
||||
bool get listening => _on;
|
||||
|
||||
/// The words of the listen under way so far.
|
||||
String get heard => _heard;
|
||||
|
||||
/// Starts listening in [language] (the device's own where null); false,
|
||||
/// and off again, where the device can't listen.
|
||||
Future<bool> start({required String? language}) async {
|
||||
if (_on) {
|
||||
return true;
|
||||
}
|
||||
_on = true;
|
||||
_silences = 0;
|
||||
_language = language;
|
||||
notifyListeners();
|
||||
return await _listenOnce();
|
||||
}
|
||||
|
||||
Future<bool> _listenOnce() async {
|
||||
final listen = ++_listen;
|
||||
final insertion = DictationInsertion.at(_text.value);
|
||||
_heard = '';
|
||||
final listens = await _speech.listen(
|
||||
language: _language,
|
||||
onWords: (words) {
|
||||
if (listen == _listen && _on) {
|
||||
_heard = words;
|
||||
_text.value = insertion.withWords(words);
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (listen == _listen && _on) {
|
||||
unawaited(_paused());
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!listens && listen == _listen) {
|
||||
_off();
|
||||
}
|
||||
return listens;
|
||||
}
|
||||
|
||||
/// A listen ended by itself: again, unless it keeps hearing nothing.
|
||||
Future<void> _paused() async {
|
||||
_silences = _heard.isEmpty ? _silences + 1 : 0;
|
||||
if (_silences >= silencesBeforeStopping) {
|
||||
_off();
|
||||
return;
|
||||
}
|
||||
// Words heard stand as they are; the next go after them.
|
||||
await _listenOnce();
|
||||
}
|
||||
|
||||
void _off() {
|
||||
_on = false;
|
||||
_heard = '';
|
||||
_listen++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Stops listening, keeping what was written.
|
||||
Future<void> stop() async {
|
||||
if (!_on) {
|
||||
return;
|
||||
}
|
||||
_off();
|
||||
await _speech.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_on) {
|
||||
_on = false;
|
||||
_listen++;
|
||||
unawaited(_speech.stop());
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
147
packages/halkyon_app/lib/src/speech/speech_input.dart
Normal file
147
packages/halkyon_app/lib/src/speech/speech_input.dart
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:speech_to_text/speech_to_text.dart';
|
||||
|
||||
/// Turns what the person says into words (Halkyon Learn's ADR 0021, D67;
|
||||
/// Halkyon Meditate's ADR 0038, D15), through the device's own speech
|
||||
/// recognition: Android's (which may send the sound to Google or the
|
||||
/// phone's maker), Apple's, Windows'. The instance never hears it.
|
||||
abstract interface class SpeechInput() {
|
||||
/// Whether this kind of device recognises speech at all; Linux doesn't.
|
||||
/// Asks nothing, so the microphone's permission is only asked for once
|
||||
/// the person taps it.
|
||||
bool get supported;
|
||||
|
||||
/// Listens in [language] (a BCP 47 tag; the device's own language where
|
||||
/// it has none of that language), handing on all it heard so far to
|
||||
/// [onWords] as it hears it, until it stops hearing anything or [stop]
|
||||
/// is called, then calls [onDone]. False, and nothing heard, where the
|
||||
/// device can't listen now: no recogniser, or the microphone refused.
|
||||
Future<bool> listen({
|
||||
required String? language,
|
||||
required ValueChanged<String> onWords,
|
||||
required VoidCallback onDone,
|
||||
});
|
||||
|
||||
/// Stops listening, keeping what was heard.
|
||||
Future<void> stop();
|
||||
}
|
||||
|
||||
/// [SpeechInput] through `speech_to_text`.
|
||||
class DeviceSpeechInput({
|
||||
/// Whether Android is asked to recognise on the device (Android 12 on,
|
||||
/// where it can; offline before), the sound leaving it no more: where that
|
||||
/// fails before a word is heard, it listens as Android otherwise does
|
||||
/// from then on. Apple's and Windows' recognisers choose for themselves.
|
||||
final bool preferOnDevice = false,
|
||||
|
||||
/// How long a pause may last before the recogniser stops; the platform's
|
||||
/// own where null, and Android's is shorter whatever is asked.
|
||||
final Duration? pauseFor,
|
||||
|
||||
/// How long one listen may last at most; the platform's own where null.
|
||||
final Duration? listenFor,
|
||||
}) implements SpeechInput {
|
||||
final _speech = SpeechToText();
|
||||
|
||||
/// Called once listening ends, for the listen under way.
|
||||
VoidCallback? _onDone;
|
||||
|
||||
/// Whether the listen under way heard a word.
|
||||
var _heard = false;
|
||||
|
||||
/// Whether recognising on the device failed here, so it is asked no more.
|
||||
var _onDeviceFailed = false;
|
||||
|
||||
/// Whether the listen under way asked for recognition on the device.
|
||||
var _onDevice = false;
|
||||
|
||||
@override
|
||||
bool get supported => switch (defaultTargetPlatform) {
|
||||
TargetPlatform.android ||
|
||||
TargetPlatform.iOS ||
|
||||
TargetPlatform.macOS ||
|
||||
TargetPlatform.windows => true,
|
||||
TargetPlatform.linux || TargetPlatform.fuchsia => false,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<bool> listen({
|
||||
required String? language,
|
||||
required ValueChanged<String> onWords,
|
||||
required VoidCallback onDone,
|
||||
}) async {
|
||||
if (!supported ||
|
||||
!await _speech.initialize(
|
||||
onStatus: (status) {
|
||||
if (status == SpeechToText.doneStatus ||
|
||||
status == SpeechToText.notListeningStatus) {
|
||||
_done();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
// A pause isn't a failure of the device's recogniser.
|
||||
if (_onDevice &&
|
||||
!_heard &&
|
||||
!const {
|
||||
'error_no_match',
|
||||
'error_speech_timeout',
|
||||
}.contains(error.errorMsg)) {
|
||||
_onDeviceFailed = true;
|
||||
}
|
||||
_done();
|
||||
},
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
_onDone = onDone;
|
||||
_heard = false;
|
||||
_onDevice =
|
||||
preferOnDevice &&
|
||||
!_onDeviceFailed &&
|
||||
defaultTargetPlatform == TargetPlatform.android;
|
||||
await _speech.listen(
|
||||
onResult: (result) {
|
||||
_heard = _heard || result.recognizedWords.isNotEmpty;
|
||||
onWords(result.recognizedWords);
|
||||
if (result.finalResult) {
|
||||
_done();
|
||||
}
|
||||
},
|
||||
listenOptions: SpeechListenOptions(
|
||||
localeId: language == null ? null : await _localeOf(language),
|
||||
listenMode: ListenMode.dictation,
|
||||
cancelOnError: true,
|
||||
onDevice: _onDevice,
|
||||
pauseFor: pauseFor,
|
||||
listenFor: listenFor,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
void _done() {
|
||||
final done = _onDone;
|
||||
_onDone = null;
|
||||
done?.call();
|
||||
}
|
||||
|
||||
/// The recogniser's locale for [language]: the same tag, else the first
|
||||
/// of its language; null where it has none, for the device's own.
|
||||
Future<String?> _localeOf(String language) async {
|
||||
String tag(String id) => id.replaceAll('_', '-').toLowerCase();
|
||||
final wanted = tag(language);
|
||||
final primary = wanted.split('-').first;
|
||||
final locales = [
|
||||
for (final locale in await _speech.locales()) locale.localeId,
|
||||
];
|
||||
return locales.where((id) => tag(id) == wanted).firstOrNull ??
|
||||
locales.where((id) => tag(id).split('-').first == primary).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
if (_speech.isListening) {
|
||||
await _speech.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
51
packages/halkyon_app/lib/src/testing/fake_speech_input.dart
Normal file
51
packages/halkyon_app/lib/src/testing/fake_speech_input.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_app/src/speech/speech_input.dart';
|
||||
|
||||
/// A [SpeechInput] the test speaks into: [say] hands words on, [finish]
|
||||
/// ends the listen.
|
||||
class FakeSpeechInput() implements SpeechInput {
|
||||
@override
|
||||
bool supported = true;
|
||||
|
||||
/// Whether the device can listen when asked: false as if the microphone
|
||||
/// were refused.
|
||||
bool listens = true;
|
||||
|
||||
/// The languages asked to listen in, in order.
|
||||
final languages = <String?>[];
|
||||
|
||||
ValueChanged<String>? _onWords;
|
||||
VoidCallback? _onDone;
|
||||
|
||||
/// Whether a listen is under way.
|
||||
bool get listening => _onWords != null;
|
||||
|
||||
/// Hands on [words], as all heard so far.
|
||||
void say(String words) => _onWords?.call(words);
|
||||
|
||||
/// Ends the listen, as the recogniser does after a pause.
|
||||
void finish() {
|
||||
final done = _onDone;
|
||||
_onWords = null;
|
||||
_onDone = null;
|
||||
done?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> listen({
|
||||
required String? language,
|
||||
required ValueChanged<String> onWords,
|
||||
required VoidCallback onDone,
|
||||
}) async {
|
||||
languages.add(language);
|
||||
if (!listens) {
|
||||
return false;
|
||||
}
|
||||
_onWords = onWords;
|
||||
_onDone = onDone;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async => finish();
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ export 'src/testing/fake_password_gateway.dart';
|
|||
export 'src/testing/fake_public_profile_gateway.dart';
|
||||
export 'src/testing/fake_screen_guard.dart';
|
||||
export 'src/testing/fake_simplelogin_gateway.dart';
|
||||
export 'src/testing/fake_speech_input.dart';
|
||||
export 'src/testing/memory_account_key_store.dart';
|
||||
export 'src/testing/memory_account_wait_store.dart';
|
||||
export 'src/testing/memory_file_saver.dart';
|
||||
|
|
|
|||
|
|
@ -69,6 +69,11 @@ dependencies:
|
|||
# libsodium's API, as halkyon_keys pins it (the same version, exactly):
|
||||
# opening it on each isolate the account's keys are worked on.
|
||||
sodium: 4.1.0+1
|
||||
# What the person says, written (SpeechInput, Dictation; Halkyon Learn's
|
||||
# ADR 0021, D67, Halkyon Meditate's ADR 0038, D15): the device's own speech
|
||||
# recognition (Android's, Apple's, Windows'); none on Linux. An app that
|
||||
# listens declares the microphone and its usage texts itself.
|
||||
speech_to_text: ^7.5.0
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
|||
168
packages/halkyon_app/test/speech/dictation_test.dart
Normal file
168
packages/halkyon_app/test/speech/dictation_test.dart
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
|
||||
/// Dictating into a text (Halkyon Meditate's ADR 0038, D15): the words
|
||||
/// heard go where the cursor stands, set apart by a space, and listening
|
||||
/// starts again after the recogniser's pause while the person leaves it
|
||||
/// on, until it hears nothing a few times running.
|
||||
void main() {
|
||||
group('the words heard go', () {
|
||||
TextEditingValue at(String text, int offset) => TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: offset),
|
||||
);
|
||||
|
||||
test('after the text, a space between, the cursor after them', () {
|
||||
final value = DictationInsertion.at(at('I was', 5)).withWords('at sea');
|
||||
expect(value.text, 'I was at sea');
|
||||
expect(value.selection, const TextSelection.collapsed(offset: 12));
|
||||
});
|
||||
|
||||
test('where the cursor stands, set apart on both sides', () {
|
||||
final value = DictationInsertion.at(
|
||||
at('I was there.', 5),
|
||||
).withWords('at sea');
|
||||
expect(value.text, 'I was at sea there.');
|
||||
expect(value.selection, const TextSelection.collapsed(offset: 12));
|
||||
});
|
||||
|
||||
test('with no space added beside one there already, nor at the '
|
||||
"text's ends", () {
|
||||
expect(
|
||||
DictationInsertion.at(at('', 0)).withWords('A house').text,
|
||||
'A house',
|
||||
);
|
||||
expect(
|
||||
DictationInsertion.at(at('I was ', 6)).withWords('home').text,
|
||||
'I was home',
|
||||
);
|
||||
expect(
|
||||
DictationInsertion.at(at(' there', 0)).withWords('Out').text,
|
||||
'Out there',
|
||||
);
|
||||
});
|
||||
|
||||
test('in place of what is selected, as typing does', () {
|
||||
final value = DictationInsertion.at(
|
||||
const TextEditingValue(
|
||||
text: 'a red house',
|
||||
selection: TextSelection(baseOffset: 2, extentOffset: 5),
|
||||
),
|
||||
).withWords('blue');
|
||||
expect(value.text, 'a blue house');
|
||||
});
|
||||
|
||||
test('at the end where the field has no cursor yet', () {
|
||||
expect(
|
||||
DictationInsertion.at(const TextEditingValue(text: 'I was'))
|
||||
.withWords('home')
|
||||
.text,
|
||||
'I was home',
|
||||
);
|
||||
});
|
||||
|
||||
test('nothing heard leaves the text as it was', () {
|
||||
expect(DictationInsertion.at(at('I was', 5)).withWords('').text, 'I was');
|
||||
});
|
||||
});
|
||||
|
||||
group('a dictation', () {
|
||||
late FakeSpeechInput speech;
|
||||
late TextEditingController text;
|
||||
late Dictation dictation;
|
||||
|
||||
setUp(() {
|
||||
speech = FakeSpeechInput();
|
||||
text = TextEditingController();
|
||||
dictation = Dictation(speech, text);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
dictation.dispose();
|
||||
text.dispose();
|
||||
});
|
||||
|
||||
test('listens in the language asked, writing the words as they come, '
|
||||
'and shows them', () async {
|
||||
expect(await dictation.start(language: 'de'), isTrue);
|
||||
expect(dictation.listening, isTrue);
|
||||
expect(speech.languages, ['de']);
|
||||
|
||||
speech.say('Ich war');
|
||||
expect(dictation.heard, 'Ich war');
|
||||
speech.say('Ich war am Meer');
|
||||
expect(text.text, 'Ich war am Meer');
|
||||
});
|
||||
|
||||
test('stopped, it listens no more and keeps what it wrote', () async {
|
||||
await dictation.start(language: 'en');
|
||||
speech.say('A house');
|
||||
|
||||
await dictation.stop();
|
||||
expect(dictation.listening, isFalse);
|
||||
expect(speech.listening, isFalse);
|
||||
expect(text.text, 'A house');
|
||||
speech.say('ignored');
|
||||
expect(text.text, 'A house');
|
||||
});
|
||||
|
||||
test("after the recogniser's pause it listens again, the next words "
|
||||
'after the last', () async {
|
||||
await dictation.start(language: 'en');
|
||||
speech
|
||||
..say('A house')
|
||||
..finish();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(dictation.listening, isTrue);
|
||||
expect(speech.listening, isTrue);
|
||||
expect(speech.languages, ['en', 'en']);
|
||||
speech.say('by the sea');
|
||||
expect(text.text, 'A house by the sea');
|
||||
});
|
||||
|
||||
test(
|
||||
'it stops by itself after hearing nothing three times running',
|
||||
() async {
|
||||
await dictation.start(language: 'en');
|
||||
for (var pause = 0; pause < Dictation.silencesBeforeStopping; pause++) {
|
||||
expect(dictation.listening, isTrue, reason: 'pause $pause');
|
||||
speech.finish();
|
||||
await pumpEventQueue();
|
||||
}
|
||||
expect(dictation.listening, isFalse);
|
||||
expect(speech.listening, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('words heard count the silences again from none', () async {
|
||||
await dictation.start(language: 'en');
|
||||
speech.finish();
|
||||
await pumpEventQueue();
|
||||
speech
|
||||
..say('A house')
|
||||
..finish();
|
||||
await pumpEventQueue();
|
||||
speech.finish();
|
||||
await pumpEventQueue();
|
||||
expect(dictation.listening, isTrue);
|
||||
});
|
||||
|
||||
test('where the device cannot listen, it says so and stays off', () async {
|
||||
speech.listens = false;
|
||||
expect(await dictation.start(language: 'en'), isFalse);
|
||||
expect(dictation.listening, isFalse);
|
||||
});
|
||||
|
||||
test('tells its listeners as it starts, hears and stops', () async {
|
||||
var told = 0;
|
||||
dictation.addListener(() => told++);
|
||||
await dictation.start(language: 'en');
|
||||
speech.say('A');
|
||||
await dictation.stop();
|
||||
expect(told, greaterThanOrEqualTo(3));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue