Skip to content

feat(patient): add voice input/output to chatbot (NeuroBot)#176

Open
hasanravda wants to merge 7 commits intoAOSSIE-Org:mainfrom
hasanravda:patient-voice
Open

feat(patient): add voice input/output to chatbot (NeuroBot)#176
hasanravda wants to merge 7 commits intoAOSSIE-Org:mainfrom
hasanravda:patient-voice

Conversation

@hasanravda
Copy link
Copy Markdown
Member

@hasanravda hasanravda commented Mar 3, 2026

Closes #175

📝 Description

Adds full voice capability to the patient-side NeuroBot chatbot screen. Patients can now speak their messages instead of typing, and the bot's responses are read aloud automatically using text-to-speech (TTS).

🔧 Changes Made

  • Added speech_to_text: ^7.0.0 and flutter_tts: ^3.8.5 to pubsec.yaml of patient app
  • Added VoiceService (singleton) at wraps SpeechToText and FlutterTts with broadcast streams for listening/speaking state
  • Updated chatbot_screen.dart to integrate VoiceService:
    • Mic button added alongside the send button - red when actively listening, default color when idle
    • Recognized speech auto-fills the text field and sends the message
    • Each new bot reply is automatically spoken via TTS
  • Bumped Kotlin to 1.9.25 in settings.gradle to fix speech_to_text 7.x compile error

📷 Screenshots or Visual Changes (if applicable)

WhatsApp.Video.2026-03-04.at.4.09.34.AM.mp4

✅ Checklist

  • I have read the contributing guidelines.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have added necessary documentation (if applicable).
  • Any dependent changes have been merged and published in downstream modules.

Summary by CodeRabbit

  • New Features

    • Voice input and output in the chatbot: press a mic to speak, type or speak in the input field, and hear chatbot responses read aloud; mic icon and hint text update with listening state.
  • Chores

    • Added microphone and speech-recognition permission prompts (Android/iOS) and RECORD_AUDIO permission.
    • Added voice-related dependencies and updated Kotlin Android build plugin.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 3, 2026

📝 Walkthrough

Walkthrough

Adds voice input/output to the Patient app: Android RECORD_AUDIO permission and iOS microphone/speech usage strings, Kotlin Gradle plugin bump, new Dart VoiceService (STT/TTS) and dependencies, plus ChatbotScreen integration for listening, speech-to-text input, and text-to-speech output.

Changes

Cohort / File(s) Summary
Android & iOS platform files
patient/android/app/src/main/AndroidManifest.xml, patient/ios/Runner/Info.plist
Added Android RECORD_AUDIO permission and iOS NSMicrophoneUsageDescription / NSSpeechRecognitionUsageDescription entries.
Build config
patient/android/settings.gradle
Bumped Kotlin Android Gradle plugin declaration from 1.8.221.9.25 (apply false retained).
Dependencies
patient/pubspec.yaml
Added voice packages: speech_to_text: ^7.0.0 and flutter_tts: ^3.8.5.
Voice service core
patient/lib/core/services/voice_service.dart
New singleton VoiceService coordinating SpeechToText and FlutterTts with init/start/stop/speak APIs, streams and lifecycle management.
Chatbot UI integration
patient/lib/presentation/chatbot/chatbot_screen.dart
Integrated VoiceService: subscriptions to speech/listening/message streams, mic toggle UI, STT auto-fill/send, TTS for assistant messages, and dispose cleanup.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant Chatbot as ChatbotScreen
    participant Voice as VoiceService
    participant STT as SpeechToText
    participant TTS as FlutterTts
    participant Chat as ChatManager

    User->>Chatbot: Tap mic button
    Chatbot->>Voice: startListening()
    Voice->>STT: initialize & startListening()
    STT-->>Voice: recognized text
    Voice-->>Chatbot: speechStream update
    Chatbot->>Chat: submit message
    Chat-->>Chatbot: assistant message
    Chatbot->>Voice: speak(assistantText)
    Voice->>TTS: synthesize & play
    TTS-->>User: audio output
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • mdmohsin7

Poem

🐰 I hopped into code with a cheerful squeak,
I taught the app to hear each patient speak,
Mic button ready, replies voiced on cue,
Type or say — conversations bloom anew,
🎙️🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding voice input/output to the chatbot screen.
Linked Issues check ✅ Passed The PR successfully implements all core objectives from issue #175: microphone icon integration, speech-to-text, visual feedback, permission handling, and TTS for bot replies.
Out of Scope Changes check ✅ Passed All changes align with objectives. The Kotlin version bump, dependencies, and platform permissions are directly necessary for voice functionality.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
patient/lib/core/services/voice_service.dart (1)

137-141: Avoid exposing a terminal dispose() on an app-wide singleton.

After these controllers are closed once, the singleton cannot be safely reused. Consider making disposal internal/testing-only, or rebuilding controllers on re-init.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@patient/lib/core/services/voice_service.dart` around lines 137 - 141, The
public dispose() on the app-wide singleton closes _speechController,
_listeningController, and _speakingController and makes the singleton unusable;
make disposal internal or resilient instead: either rename dispose() to a
private/test-only method (e.g., _disposeForTesting()) and annotate for testing,
or change dispose() to check-and-recreate controllers on next use (guard with
isClosed checks and lazily reinitialize _speechController, _listeningController,
_speakingController in their getters/initializers) so the singleton can be
safely reused; update any callers/tests accordingly to use the new
private/test-only method or rely on lazy init.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@patient/android/app/src/main/AndroidManifest.xml`:
- Line 3: The iOS patient app is missing the NSMicrophoneUsageDescription and
NSSpeechRecognitionUsageDescription keys in its Info.plist, which will crash
voice capture at runtime; open the patient app's Info.plist (iOS Runner
Info.plist) and add NSMicrophoneUsageDescription and
NSSpeechRecognitionUsageDescription entries with clear, user-facing strings
(e.g., why the app needs microphone access and why speech recognition is used),
mirroring the therapist app's plist wording as a reference to ensure consistency
and proper localization.

In `@patient/lib/core/services/voice_service.dart`:
- Around line 30-47: The first initialize call on _speechToText inside
initialize() currently ignores its boolean result and startListening() re-calls
initialize() redundantly; capture the bool returned by
_speechToText.initialize(...) (e.g. assign to a new or existing flag like
_speechAvailable or reuse _isInitialized) when running initialize(), store that
result, and remove the second initialize() call from startListening(); then have
startListening() check the captured flag (and _isInitialized) to guard starting
the listening flow. Ensure you update references to initialize(), _speechToText,
and startListening() so the initialization result is used rather than
re-invoking initialize().

In `@patient/lib/presentation/chatbot/chatbot_screen.dart`:
- Line 20: The deduplication currently compares bot replies by text via the
_lastSpokenText field which suppresses valid repeated replies across different
turns; change the logic to dedupe by a stable message identifier instead:
introduce a new field like _lastSpokenMessageId, update the speak/tts trigger to
compare the incoming message.id (or timestamp/uniqueTurnId) against
_lastSpokenMessageId rather than the message text, and set _lastSpokenMessageId
when a message is actually spoken; update any usages around where
_lastSpokenText is referenced (including the block at lines 45-49) to use the
new identifier-based check so identical text in different turns will still be
spoken.
- Around line 64-69: In _toggleListening(), handle the boolean result returned
by _voiceService.startListening(): call startListening() into a variable, check
if it returned true before updating _isListening or relying on listening state,
and on false handle the failure path (e.g., show user feedback via
ScaffoldMessenger/SnackBar or update UI state and log the error); ensure the
stopListening() branch remains unchanged and that any state changes to
_isListening or UI are performed inside setState only when startListening()
succeeded.

---

Nitpick comments:
In `@patient/lib/core/services/voice_service.dart`:
- Around line 137-141: The public dispose() on the app-wide singleton closes
_speechController, _listeningController, and _speakingController and makes the
singleton unusable; make disposal internal or resilient instead: either rename
dispose() to a private/test-only method (e.g., _disposeForTesting()) and
annotate for testing, or change dispose() to check-and-recreate controllers on
next use (guard with isClosed checks and lazily reinitialize _speechController,
_listeningController, _speakingController in their getters/initializers) so the
singleton can be safely reused; update any callers/tests accordingly to use the
new private/test-only method or rely on lazy init.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 421e7c2 and 726f735.

📒 Files selected for processing (5)
  • patient/android/app/src/main/AndroidManifest.xml
  • patient/android/settings.gradle
  • patient/lib/core/services/voice_service.dart
  • patient/lib/presentation/chatbot/chatbot_screen.dart
  • patient/pubspec.yaml

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@patient/lib/core/services/voice_service.dart`:
- Around line 137-140: The dispose() method currently closes singleton
StreamControllers (_speechController, _listeningController, _speakingController)
which breaks reuse and causes add-after-close errors; remove the calls that
close those controllers from dispose(), instead set a private boolean
_isDisposed flag in dispose(), and update startListening(), speak(), and any
async STT/TTS callbacks to check if (!_isDisposed) before calling add/emit to
those controllers; if you need to fully tear down, add a separate
shutdownOrReset() method that safely closes and recreates controllers so
singleton reuse remains safe.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 726f735 and f0c31dc.

📒 Files selected for processing (2)
  • patient/ios/Runner/Info.plist
  • patient/lib/core/services/voice_service.dart

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
patient/lib/core/services/voice_service.dart (4)

150-154: State not explicitly updated after stopSpeaking().

Similar to the speak() method, _isSpeaking is not explicitly set to false after stop(). This relies on the completion/error handler firing, which may not happen on all platforms when speech is manually stopped.

♻️ Explicit state update
   Future<void> stopSpeaking() async {
     if (_isSpeaking) {
       await _flutterTts.stop();
+      _isSpeaking = false;
+      _emitSpeaking(_isSpeaking);
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@patient/lib/core/services/voice_service.dart` around lines 150 - 154, After
calling _flutterTts.stop() in stopSpeaking(), explicitly set the internal
speaking state variable (_isSpeaking) to false immediately after the await to
ensure consistent state across platforms; update the stopSpeaking() method to
await _flutterTts.stop(), then set _isSpeaking = false and (optionally) notify
any listeners or emit the same state change path used by speak() so callers rely
on the explicit state instead of depending solely on completion/error handlers.

104-117: Consider setting partialResults: false since only final results are used.

partialResults: true (line 112) causes onResult to fire repeatedly with intermediate transcriptions, but the callback only emits when result.finalResult is true (line 106). Setting partialResults: false would reduce unnecessary callback invocations.

♻️ Suggested change
       await _speechToText.listen(
         onResult: (result) {
           if (result.finalResult) {
             _emitSpeech(result.recognizedWords);
           }
         },
         listenFor: const Duration(seconds: 30),
         pauseFor: const Duration(seconds: 3),
-        partialResults: true,
+        partialResults: false,
         localeId: "en_US",
         onSoundLevelChange: (level) {
           // Optional: Handle sound level changes
         },
       );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@patient/lib/core/services/voice_service.dart` around lines 104 - 117, The
listen call on _speechToText currently sets partialResults: true but only acts
when result.finalResult is true; change the listen invocation in the
_speechToText.listen call to set partialResults: false so onResult is only
invoked for final results and reduce unnecessary callbacks—locate the
_speechToText.listen block that uses onResult and _emitSpeech and update the
partialResults flag accordingly.

136-148: Potential state inconsistency after stop() before new speak().

When _isSpeaking is true and _flutterTts.stop() is called (line 140), the setCompletionHandler may or may not fire depending on platform behavior. If it doesn't fire, _isSpeaking remains true until the new speech starts and triggers setStartHandler. This creates a brief window where _isSpeaking is inconsistent.

♻️ Explicit state reset after stop
   Future<void> speak(String text) async {
     if (!_isInitialized) await initialize();
     
     if (_isSpeaking) {
       await _flutterTts.stop();
+      _isSpeaking = false;
+      _emitSpeaking(_isSpeaking);
     }
     
     try {
       await _flutterTts.speak(text);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@patient/lib/core/services/voice_service.dart` around lines 136 - 148, The
speak() method can leave _isSpeaking true if _flutterTts.stop() doesn't trigger
the completion handler; after awaiting _flutterTts.stop() in speak(), explicitly
reset the internal speaking state (e.g. set _isSpeaking = false and notify any
listeners/handlers) before calling _flutterTts.speak(text) so state is
consistent; reference the speak() method, the _flutterTts.stop() call, the
_isSpeaking flag, and the existing setCompletionHandler/setStartHandler logic
when applying the change.

15-23: Singleton cannot recover after dispose() is called.

Once dispose() is called, _isDisposed = true and controllers are closed. However, the singleton instance remains, and any subsequent calls to initialize() will return early due to _isInitialized still being true, while the streams are permanently closed. If the chatbot screen is revisited after dispose() is called elsewhere, voice features will silently fail.

Based on the relevant context from chatbot_screen.dart (lines 86-93), dispose() on the screen does NOT call _voiceService.dispose(), so this may not be a current issue—but the API is misleading. Consider either:

  1. Removing dispose() entirely (singleton lives for app lifetime), or
  2. Adding a reset() method that properly recreates controllers for reuse.
♻️ Option 1: Remove dispose or make it a no-op for singleton safety
-  Future<void> dispose() async {
-    if (_isDisposed) return;
-    _isDisposed = true;
-    await _speechToText.stop();
-    await _flutterTts.stop();
-    await _speechController.close();
-    await _listeningController.close();
-    await _speakingController.close();
-  }
+  /// Stops active STT/TTS but keeps the singleton usable.
+  /// Call this when the app is backgrounded or voice features are no longer needed.
+  Future<void> stopAll() async {
+    await _speechToText.stop();
+    await _flutterTts.stop();
+    _isListening = false;
+    _isSpeaking = false;
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@patient/lib/core/services/voice_service.dart` around lines 15 - 23, The
singleton's dispose() currently closes
_speechController/_listeningController/_speakingController and sets _isDisposed,
leaving the singleton unusable because initialize() returns early when
_isInitialized is still true; add a public reset() method that re-creates the
three StreamControllers, clears _isDisposed and _isInitialized (and any other
internal state like _isListening/_isSpeaking), and update initialize() to call
reset() or recreate controllers when it detects _isDisposed so the singleton can
be re-initialized; alternatively, if you prefer lifecycle simpler, make
dispose() a no-op for the singleton (leave controllers open) and remove setting
_isDisposed, but do not both close controllers and keep initialize() returning
early. Ensure you reference dispose(), initialize(), _isDisposed,
_isInitialized, _speechController, _listeningController, and _speakingController
when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@patient/lib/core/services/voice_service.dart`:
- Around line 150-154: After calling _flutterTts.stop() in stopSpeaking(),
explicitly set the internal speaking state variable (_isSpeaking) to false
immediately after the await to ensure consistent state across platforms; update
the stopSpeaking() method to await _flutterTts.stop(), then set _isSpeaking =
false and (optionally) notify any listeners or emit the same state change path
used by speak() so callers rely on the explicit state instead of depending
solely on completion/error handlers.
- Around line 104-117: The listen call on _speechToText currently sets
partialResults: true but only acts when result.finalResult is true; change the
listen invocation in the _speechToText.listen call to set partialResults: false
so onResult is only invoked for final results and reduce unnecessary
callbacks—locate the _speechToText.listen block that uses onResult and
_emitSpeech and update the partialResults flag accordingly.
- Around line 136-148: The speak() method can leave _isSpeaking true if
_flutterTts.stop() doesn't trigger the completion handler; after awaiting
_flutterTts.stop() in speak(), explicitly reset the internal speaking state
(e.g. set _isSpeaking = false and notify any listeners/handlers) before calling
_flutterTts.speak(text) so state is consistent; reference the speak() method,
the _flutterTts.stop() call, the _isSpeaking flag, and the existing
setCompletionHandler/setStartHandler logic when applying the change.
- Around line 15-23: The singleton's dispose() currently closes
_speechController/_listeningController/_speakingController and sets _isDisposed,
leaving the singleton unusable because initialize() returns early when
_isInitialized is still true; add a public reset() method that re-creates the
three StreamControllers, clears _isDisposed and _isInitialized (and any other
internal state like _isListening/_isSpeaking), and update initialize() to call
reset() or recreate controllers when it detects _isDisposed so the singleton can
be re-initialized; alternatively, if you prefer lifecycle simpler, make
dispose() a no-op for the singleton (leave controllers open) and remove setting
_isDisposed, but do not both close controllers and keep initialize() returning
early. Ensure you reference dispose(), initialize(), _isDisposed,
_isInitialized, _speechController, _listeningController, and _speakingController
when making the change.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f0c31dc and 9d152ac.

📒 Files selected for processing (2)
  • patient/lib/core/services/voice_service.dart
  • patient/lib/presentation/chatbot/chatbot_screen.dart

@hasanravda
Copy link
Copy Markdown
Member Author

Can you review this @jddeep ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FEATURE REQUEST: Add Voice Input Support to Patient App

1 participant