feat(learn): voice mode, a conversation spoken as Claude's "Let's talk" (ADR 0021, D68)
Beside the field's microphone, a button opens voice mode over the whole screen: a glow rising from below, a big microphone, and +, who answers, the speaker and End in one row. It listens with the device's recogniser, asks what was heard in one kept conversation, reads the answer aloud with the device's speech (flutter_tts, its Markdown left out) and listens again. On Linux there is none, as there is no microphone; where nothing reads aloud, answers are shown as text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7be31dcbfc
commit
c984678c5b
2 changed files with 1180 additions and 6 deletions
|
|
@ -35,6 +35,7 @@ import '../../support/fake_conversation_gateway.dart';
|
|||
import '../../support/fake_grammar_gateway.dart';
|
||||
import '../../support/fake_photo_picker.dart';
|
||||
import '../../support/fake_settings_gateway.dart';
|
||||
import '../../support/fake_speech_output.dart';
|
||||
import '../../support/fake_studyset_gateway.dart';
|
||||
import '../../support/memory_asset_file_cache.dart';
|
||||
import '../../support/memory_device_settings_store.dart';
|
||||
|
|
@ -119,6 +120,8 @@ void main() {
|
|||
late FakeLlmClient llm;
|
||||
late FakeFilePicker picker;
|
||||
late FakePhotoPicker photos;
|
||||
late FakeSpeechInput speech;
|
||||
late FakeSpeechOutput voice;
|
||||
late MemoryDeviceSettingsStore store;
|
||||
late List<Uri> searched;
|
||||
late FakeAssetGateway assetGateway;
|
||||
|
|
@ -146,6 +149,8 @@ void main() {
|
|||
llm = FakeLlmClient();
|
||||
picker = FakeFilePicker();
|
||||
photos = FakePhotoPicker();
|
||||
speech = FakeSpeechInput();
|
||||
voice = FakeSpeechOutput();
|
||||
searched = [];
|
||||
assetGateway = FakeAssetGateway();
|
||||
assetFiles = MemoryAssetFileCache();
|
||||
|
|
@ -208,6 +213,8 @@ void main() {
|
|||
grammarLanguages: grammarLanguages,
|
||||
files: picker,
|
||||
photos: photos,
|
||||
speech: speech,
|
||||
speechOutput: voice,
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
|
|
@ -361,8 +368,10 @@ void main() {
|
|||
);
|
||||
final history = at('Conversations');
|
||||
final speak = at('Speak your question');
|
||||
final talk = at('Voice mode');
|
||||
final field = tester.getRect(find.byType(AssistantField));
|
||||
expect(field.right - speak.right, lessThan(16), reason: 'at the right');
|
||||
expect(field.right - talk.right, lessThan(16), reason: 'at the right');
|
||||
expect(speak.right, lessThan(talk.left), reason: 'the microphone before');
|
||||
|
||||
await openPicker(tester);
|
||||
await tester.tap(find.text('Claude Opus, thinking longer').last);
|
||||
|
|
@ -370,6 +379,7 @@ void main() {
|
|||
expect(find.text('Ask Claude Opus, thinking longer…'), findsOneWidget);
|
||||
expect(at('Conversations'), history);
|
||||
expect(at('Speak your question'), speak);
|
||||
expect(at('Voice mode'), talk);
|
||||
|
||||
for (final icon in [
|
||||
LucideIcons.plus,
|
||||
|
|
@ -1549,4 +1559,129 @@ void main() {
|
|||
expect(find.text('Was not shown an image'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group("voice mode, as Claude's (D68)", () {
|
||||
/// Opens voice mode from the field.
|
||||
Future<void> talk(WidgetTester tester) async {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byTooltip('Voice mode'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Says [words] and stops, as the recogniser does after a pause, and
|
||||
/// lets the answer come.
|
||||
Future<void> say(WidgetTester tester, String words) async {
|
||||
speech.say(words);
|
||||
await tester.pump();
|
||||
speech.finish();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('the field opens it over the whole screen, no bar beneath; '
|
||||
'it listens, asks what was said, reads the answer aloud without its '
|
||||
'marks, and listens again; ending it keeps the conversation', (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester);
|
||||
await talk(tester);
|
||||
|
||||
expect(find.text("Let's talk"), findsOneWidget);
|
||||
expect(find.text('Home'), findsNothing, reason: 'no navigation bar');
|
||||
expect(speech.listening, isTrue);
|
||||
expect(speech.languages.single, 'en-US');
|
||||
|
||||
assistants.answers.add(_says('Because it **joins** clauses.'));
|
||||
speech.say('Why て-form?');
|
||||
await tester.pump();
|
||||
expect(find.text('Why て-form?'), findsOneWidget, reason: 'as heard');
|
||||
speech.finish();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_asked(assistants.asked.single.$2), startsWith('Why て-form?'));
|
||||
expect(voice.spoken.single, ('Because it joins clauses.', 'en-US'));
|
||||
expect(
|
||||
find.text('Because it **joins** clauses.', findRichText: true),
|
||||
findsNothing,
|
||||
);
|
||||
expect(speech.listening, isTrue, reason: 'listening again');
|
||||
|
||||
await tester.tap(find.byTooltip('End voice mode'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("Let's talk"), findsNothing);
|
||||
expect(speech.listening, isFalse);
|
||||
expect(kept.written, isNotEmpty, reason: 'kept, as any conversation');
|
||||
});
|
||||
|
||||
testWidgets('a second question goes on the same conversation', (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester);
|
||||
await talk(tester);
|
||||
assistants.answers
|
||||
..add(_says('It joins.'))
|
||||
..add(_says('As in 食べて.'));
|
||||
|
||||
await say(tester, 'Why て-form?');
|
||||
await say(tester, 'An example?');
|
||||
|
||||
final second = assistants.asked.last.$2;
|
||||
expect(_asked(second), startsWith('An example?'));
|
||||
expect(second.messages, hasLength(3), reason: 'the first turn before');
|
||||
});
|
||||
|
||||
testWidgets('with the speaker off, answers are shown, not read, and it '
|
||||
'listens again at once', (tester) async {
|
||||
await showApp(tester);
|
||||
await talk(tester);
|
||||
await tester.tap(find.byTooltip("Don't read answers aloud"));
|
||||
await tester.pumpAndSettle();
|
||||
assistants.answers.add(_says('Because it joins clauses.'));
|
||||
|
||||
await say(tester, 'Why て-form?');
|
||||
|
||||
expect(voice.spoken, isEmpty);
|
||||
expect(
|
||||
find.text('Because it joins clauses.', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(speech.listening, isTrue);
|
||||
expect(find.byTooltip('Read answers aloud'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the microphone stops the reading and listens at once; '
|
||||
'nothing heard, it waits for the microphone', (tester) async {
|
||||
voice.holds = true;
|
||||
await showApp(tester);
|
||||
await talk(tester);
|
||||
assistants.answers.add(_says('A long answer.'));
|
||||
await say(tester, 'Why?');
|
||||
expect(voice.speaking, isTrue);
|
||||
expect(speech.listening, isFalse);
|
||||
|
||||
await tester.tap(find.byTooltip('Talk'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(voice.speaking, isFalse);
|
||||
expect(speech.listening, isTrue);
|
||||
|
||||
speech.finish();
|
||||
await tester.pumpAndSettle();
|
||||
expect(speech.listening, isFalse, reason: 'nothing heard');
|
||||
expect(find.text('Tap the microphone to talk'), findsOneWidget);
|
||||
expect(assistants.asked, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('only for an assistant, and only where the device listens', (
|
||||
tester,
|
||||
) async {
|
||||
speech.supported = false;
|
||||
await showApp(tester);
|
||||
expect(find.byTooltip('Voice mode'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/speech_output.dart';
|
||||
|
||||
void main() {
|
||||
test('an answer is read without its marks, a link as its words, a '
|
||||
"list's items as lines (ADR 0021, D68)", () {
|
||||
expect(
|
||||
speakableText(
|
||||
'## Why て-form?\n\n'
|
||||
'It **joins** clauses, as `食べて` does.\n\n'
|
||||
'- First, *this*.\n'
|
||||
'1. Then [that one](https://example.org).\n'
|
||||
'> A quote.',
|
||||
),
|
||||
'Why て-form?\n\n'
|
||||
'It joins clauses, as 食べて does.\n\n'
|
||||
'First, this.\n'
|
||||
'Then that one.\n'
|
||||
'A quote.',
|
||||
);
|
||||
});
|
||||
|
||||
test('a table reads as its cells, a code block as its code', () {
|
||||
expect(
|
||||
speakableText('| a | b |\n|---|---|\n| 1 | 2 |'),
|
||||
contains('1, 2'),
|
||||
);
|
||||
expect(speakableText('```dart\nprint(1);\n```'), 'print(1);');
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue