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:
Tizian 「ティツィアーン」 2026-11-03 14:41:07 +01:00
commit 603218ef96
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 405 additions and 16 deletions

View file

@ -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';

View 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();
}
}

View 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();
}
}
}

View 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();
}

View file

@ -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';

View file

@ -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:

View 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));
});
});
}