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
16 changed files with 405 additions and 16 deletions

File diff suppressed because one or more lines are too long

View file

@ -27,7 +27,6 @@ import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.da
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/speech_input.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/sync_conversation_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';

View file

@ -1,105 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:speech_to_text/speech_to_text.dart';
/// Turns what the person says into the words of a question (ADR 0021,
/// D67), 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() implements SpeechInput {
final _speech = SpeechToText();
/// Called once listening ends, for the listen under way.
VoidCallback? _onDone;
@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: (_) => _done(),
)) {
return false;
}
_onDone = onDone;
await _speech.listen(
onResult: (result) {
onWords(result.recognizedWords);
if (result.finalResult) {
_done();
}
},
listenOptions: SpeechListenOptions(
localeId: language == null ? null : await _localeOf(language),
listenMode: ListenMode.dictation,
cancelOnError: true,
),
);
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

@ -132,9 +132,6 @@ dependencies:
serverpod_auth_idp_flutter: 4.0.4
serverpod_flutter: 4.0.4
serverpod_offline_sync_client: 0.0.10
# A question spoken to the assistant (ADR 0021, D67): the device's own speech
# recognition (Android's, Apple's, Windows'); none on Linux.
speech_to_text: ^7.5.0
# Anki collections are SQLite databases (the sync engine already bundles it).
sqlite3: ^3.6.0
url_launcher: ^6.3.2

View file

@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart' show FakeSpeechInput;
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
@ -17,7 +18,6 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_speech_input.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/memory_device_settings_store.dart';
import '../../support/pump_app.dart';

View file

@ -1,51 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/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

@ -24,7 +24,6 @@ import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.d
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/speech_input.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
@ -101,7 +100,6 @@ import 'fake_publish_gateway.dart';
import 'fake_review_gateway.dart';
import 'fake_settings_gateway.dart';
import 'fake_sound_player.dart';
import 'fake_speech_input.dart';
import 'fake_studyset_gateway.dart';
import 'fake_tag_gateway.dart';
import 'fake_youtube_videos.dart';

View file

@ -15,6 +15,7 @@ import flutter_web_auth_2
import google_sign_in_ios
import package_info_plus
import sign_in_with_apple
import speech_to_text
import url_launcher_macos
import wakelock_plus
import window_to_front
@ -30,6 +31,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GoogleSignInPlugin.register(with: registry.registrar(forPlugin: "GoogleSignInPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin"))

View file

@ -11,6 +11,7 @@
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <speech_to_text_windows/speech_to_text_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_to_front/window_to_front_plugin.h>
@ -25,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
SpeechToTextWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SpeechToTextWindows"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowToFrontPluginRegisterWithRegistrar(

View file

@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
desktop_webview_window
file_selector_windows
flutter_secure_storage_windows
speech_to_text_windows
url_launcher_windows
window_to_front
)

View file

@ -15,6 +15,7 @@ import flutter_web_auth_2
import google_sign_in_ios
import package_info_plus
import sign_in_with_apple
import speech_to_text
import url_launcher_macos
import window_to_front
@ -29,6 +30,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GoogleSignInPlugin.register(with: registry.registrar(forPlugin: "GoogleSignInPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin"))
}

View file

@ -10,6 +10,7 @@
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <speech_to_text_windows/speech_to_text_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_to_front/window_to_front_plugin.h>
@ -22,6 +23,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
SpeechToTextWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SpeechToTextWindows"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowToFrontPluginRegisterWithRegistrar(

View file

@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
desktop_webview_window
file_selector_windows
flutter_secure_storage_windows
speech_to_text_windows
url_launcher_windows
window_to_front
)

View file

@ -14,6 +14,7 @@ import flutter_web_auth_2
import google_sign_in_ios
import package_info_plus
import sign_in_with_apple
import speech_to_text
import url_launcher_macos
import window_to_front
@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GoogleSignInPlugin.register(with: registry.registrar(forPlugin: "GoogleSignInPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin"))
}

View file

@ -10,6 +10,7 @@
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <speech_to_text_windows/speech_to_text_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_to_front/window_to_front_plugin.h>
@ -22,6 +23,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
SpeechToTextWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SpeechToTextWindows"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowToFrontPluginRegisterWithRegistrar(

View file

@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
desktop_webview_window
file_selector_windows
flutter_secure_storage_windows
speech_to_text_windows
url_launcher_windows
window_to_front
)