diff --git a/README.md b/README.md index 861fb84..65fea4c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Das Projekt ist bewusst schlank gehalten: React Native, Expo, TypeScript und Rea - HomeScreen mit Fortschrittskarte und Mock Daily Reminder - Level-Auswahl fuer A1, A2, B1 und B2 - ChatScreen mit einfacher User- und AI-Nachrichtenansicht +- AI-Antworten koennen auf dem iPhone vorgelesen werden - Mock KI-Service fuer Beispielkorrekturen - TypeScript-typisierte Navigation - Struktur fuer spaetere Services, Hooks und Utilities @@ -32,6 +33,8 @@ Good try. Correct: "I went to work yesterday." - React Navigation - Geplant: Ollama API fuer lokale KI - Geplant: Speech-to-Text und Text-to-Speech +- Text-to-Speech mit `expo-speech` +- Spracheingabe aktuell ueber iPhone-Diktierfunktion in der Tastatur - Geplant: Push Notifications fuer Daily Reminder ## Projektstruktur @@ -104,6 +107,19 @@ App starten: npm run start ``` +## Spracheingabe und Vorlesen + +Vorlesen funktioniert direkt in Expo Go. AI-Antworten werden automatisch ueber den iPhone-Lautsprecher vorgelesen. Im Chat gibt es ausserdem Buttons fuer `Letzte Antwort vorlesen` und `Stop`. + +Spracheingabe funktioniert aktuell ueber die iPhone-Tastatur: + +- Chat-Eingabefeld antippen +- Mikrofon-Symbol auf der iPhone-Tastatur druecken +- Englisch sprechen +- Text pruefen und senden + +Echtes In-App Speech-to-Text mit eigenem Mikrofon-Button ist fuer spaeter geplant. Dafuer braucht die App entweder ein natives iOS-Modul nach `expo prebuild` oder eine Transkriptions-API wie Whisper. + iOS Preview starten: ```bash diff --git a/package-lock.json b/package-lock.json index d465b2d..a380122 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@react-navigation/native": "^7.2.2", "@react-navigation/native-stack": "^7.14.12", "expo": "~54.0.33", + "expo-speech": "~14.0.8", "expo-status-bar": "~3.0.9", "react": "19.1.0", "react-native": "0.81.5", @@ -4620,6 +4621,15 @@ "node": ">=20.16.0" } }, + "node_modules/expo-speech": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-speech/-/expo-speech-14.0.8.tgz", + "integrity": "sha512-UjBFCFv58nutlLw92L7kUS0ZjbOOfaTdiEv/HbjvMrT6BfldoOLLBZbaEcEhDdZK36NY/kass0Kzxk+co6vxSQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-status-bar": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", diff --git a/package.json b/package.json index 8c3e1b6..0388076 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@react-navigation/native": "^7.2.2", "@react-navigation/native-stack": "^7.14.12", "expo": "~54.0.33", + "expo-speech": "~14.0.8", "expo-status-bar": "~3.0.9", "react": "19.1.0", "react-native": "0.81.5", diff --git a/src/screens/ChatScreen.tsx b/src/screens/ChatScreen.tsx index 0584ad4..994319d 100644 --- a/src/screens/ChatScreen.tsx +++ b/src/screens/ChatScreen.tsx @@ -7,6 +7,7 @@ import { ChatBubble, type ChatMessage } from '../components/ChatBubble'; import { PrimaryButton } from '../components/PrimaryButton'; import type { RootStackParamList } from '../navigation/types'; import { getMockAiReply } from '../services/mockAiCoach'; +import { speakText, stopSpeaking } from '../services/speechService'; import { colors } from '../theme/colors'; type ChatScreenProps = NativeStackScreenProps; @@ -36,6 +37,7 @@ export function ChatScreen({ route }: ChatScreenProps) { // This mock can later be replaced by Ollama or another AI API client. const reply = await getMockAiReply(userMessage.text, level); setMessages((current) => [...current, { id: `ai-${Date.now()}`, role: 'assistant', text: reply }]); + speakText(reply); setIsSending(false); } @@ -62,7 +64,24 @@ export function ChatScreen({ route }: ChatScreenProps) { style={styles.input} value={input} /> - + + + + + + + + + { + const lastAiMessage = [...messages].reverse().find((message) => message.role === 'assistant'); + if (lastAiMessage) { + speakText(lastAiMessage.text); + } + }} + /> @@ -86,6 +105,13 @@ const styles = StyleSheet.create({ gap: 12, padding: 14, }, + actionRow: { + flexDirection: 'row', + gap: 10, + }, + actionItem: { + flex: 1, + }, input: { backgroundColor: colors.surfaceRaised, borderColor: colors.border, diff --git a/src/services/speechService.ts b/src/services/speechService.ts new file mode 100644 index 0000000..09e3a8a --- /dev/null +++ b/src/services/speechService.ts @@ -0,0 +1,18 @@ +import * as Speech from 'expo-speech'; + +export function speakText(text: string) { + Speech.stop(); + Speech.speak(stripThinkingBlocks(text), { + language: 'en-US', + pitch: 1, + rate: 0.9, + }); +} + +export function stopSpeaking() { + Speech.stop(); +} + +function stripThinkingBlocks(text: string) { + return text.replace(/[\s\S]*?<\/think>/gi, '').trim(); +}