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>
2026-11-03 14:41:07 +01:00
|
|
|
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 {
|
fix(app,learn,meditate): a device without speech recognition says so, and how to get one
On GrapheneOS, which installs no recogniser, the microphone did nothing
and stayed listening: speech_to_text's initialize throws a
PlatformException (recognizerNotAvailable) there rather than answering
false, and DeviceSpeechInput let it through. A listen now answers how it
went (SpeechStart: listening, noRecognizer, unavailable) and never
throws; Learn's field and Meditate's dream say that no speech
recognition is installed and name two to install (FUTO Voice Input,
Google's speech services), in halkyon_app's texts. It also passes
androidNoBluetooth, as the apps declare no Bluetooth permission the
plugin would otherwise ask for.
Whats-New: Where the device has no speech recognition, the microphone says so and how to get one.
Whats-New-De: Wo das Gerät keine Spracherkennung hat, sagt das Mikrofon das und wie du eine bekommst.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-04 20:45:55 +01:00
|
|
|
expect(await dictation.start(language: 'de'), SpeechStart.listening);
|
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>
2026-11-03 14:41:07 +01:00
|
|
|
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;
|
fix(app,learn,meditate): a device without speech recognition says so, and how to get one
On GrapheneOS, which installs no recogniser, the microphone did nothing
and stayed listening: speech_to_text's initialize throws a
PlatformException (recognizerNotAvailable) there rather than answering
false, and DeviceSpeechInput let it through. A listen now answers how it
went (SpeechStart: listening, noRecognizer, unavailable) and never
throws; Learn's field and Meditate's dream say that no speech
recognition is installed and name two to install (FUTO Voice Input,
Google's speech services), in halkyon_app's texts. It also passes
androidNoBluetooth, as the apps declare no Bluetooth permission the
plugin would otherwise ask for.
Whats-New: Where the device has no speech recognition, the microphone says so and how to get one.
Whats-New-De: Wo das Gerät keine Spracherkennung hat, sagt das Mikrofon das und wie du eine bekommst.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-04 20:45:55 +01:00
|
|
|
expect(await dictation.start(language: 'en'), SpeechStart.unavailable);
|
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>
2026-11-03 14:41:07 +01:00
|
|
|
expect(dictation.listening, isFalse);
|
|
|
|
|
});
|
|
|
|
|
|
fix(app,learn,meditate): a device without speech recognition says so, and how to get one
On GrapheneOS, which installs no recogniser, the microphone did nothing
and stayed listening: speech_to_text's initialize throws a
PlatformException (recognizerNotAvailable) there rather than answering
false, and DeviceSpeechInput let it through. A listen now answers how it
went (SpeechStart: listening, noRecognizer, unavailable) and never
throws; Learn's field and Meditate's dream say that no speech
recognition is installed and name two to install (FUTO Voice Input,
Google's speech services), in halkyon_app's texts. It also passes
androidNoBluetooth, as the apps declare no Bluetooth permission the
plugin would otherwise ask for.
Whats-New: Where the device has no speech recognition, the microphone says so and how to get one.
Whats-New-De: Wo das Gerät keine Spracherkennung hat, sagt das Mikrofon das und wie du eine bekommst.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-04 20:45:55 +01:00
|
|
|
test('where no recogniser is installed, it says so and stays off, '
|
|
|
|
|
'never stuck listening', () async {
|
|
|
|
|
speech.hasRecognizer = false;
|
|
|
|
|
var told = 0;
|
|
|
|
|
dictation.addListener(() => told++);
|
|
|
|
|
expect(await dictation.start(language: 'en'), SpeechStart.noRecognizer);
|
|
|
|
|
expect(dictation.listening, isFalse);
|
|
|
|
|
expect(told, 2, reason: 'on, then off again');
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-11-03 14:41:07 +01:00
|
|
|
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));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|