fix(app): voice response no longer cut off by notification chime (#7135)#7180
fix(app): voice response no longer cut off by notification chime (#7135)#7180
Conversation
Two reports on samsung S938B / Android 16 / build 833 (#7135): 1. Notification sound cuts off the voice response. 2. After 1-2 questions, voice response stops playing — only notification is delivered. Both have the same root cause: the audio_session interruption handler in OmiVoicePlaybackService called interrupt() on every event end, regardless of whether the interruption was transient or permanent. A notification chime triggers a transient pause-style focus loss; the end-of-pause event then nuked the queue, cleared _activeMessageId, and deactivated the session. Subsequent updateStreamingResponse calls were dropped because activeId was null. After 1-2 such events the user sees "voice replies don't work anymore" — actually each reply was being killed mid-flight by a chime. Fix follows the audio_session README pattern (and just_audio's own internal handler logic) — branch on event.type so only AudioInterruption Type.unknown clears the queue; pause/duck end events resume playback: - AudioPlayer constructed with handleInterruptions: false so the built-in just_audio handler does not race with ours. - New _onInterruption maps duck/pause/unknown distinctly, with a _pausedByInterruption flag so we only resume when we actually paused. - Custom AudioSessionConfiguration replaces the speech() preset: androidAudioAttributes.usage = USAGE_ASSISTANT (semantically correct for assistant feedback; influences how Android's audio policy treats notification ducking) androidWillPauseWhenDucked = false (let the OS duck volume during a brief notification rather than collapsing duck into a pause) avAudioSessionMode = voicePrompt (Apple's recommended TTS mode; previously spokenAudio which is for podcasts). - becomingNoisyEventStream still calls full interrupt() — the existing intent (don't blast the reply out of the phone speaker after unplugging headphones) is preserved. Refs #7135
Companion to the prior interruption-handler fix on this branch. For chat-reply pushes (notification_type=plugin) the backend sends an FCM with both data and a notification payload. On Android in foreground we re-display it via _showForegroundNotification, which uses the default "Omi Notifications" channel sound — that chime competes with the in-flight voice response for audio focus and is the most common trigger for the bug reported in #7135. Skip the local notification entirely when OmiVoicePlaybackService. instance.isSpeaking is true. The chat-message stream still adds the message to the chat UI; the spoken reply is the audible signal — no double-ding. iOS notes: - Foreground: already a no-op. _shouldShowForegroundNotificationOnFCM MessageReceived() returns Platform.isAndroid, and Omi never calls setForegroundNotificationPresentationOptions, so FlutterFire's willPresent default (UNNotificationPresentationOptionNone) means iOS doesn't show the banner or play sound for foreground FCMs anyway. - Background: APNS displays the notification before the Dart isolate runs; can't be suppressed client-side without a backend change. iOS audio policy ducks our voice during the chime and the prior commit's interruption handler resumes cleanly afterwards. Refs #7135
|
@morpheus review — Approved ✅ Well-researched fix. 2 atomic commits (single file each), independently revertable. Commit 1 — Interruption handler:
Commit 2 — Notification gate: Cross-platform analysis in PR body is solid. Needs device verification per test plan. |
Greptile SummaryThis PR fixes Android voice responses being cut off by notification chimes by overhauling the audio interruption handler in
Confidence Score: 3/5Safe to merge after addressing the stale The core interruption logic is well-structured and follows the audio_session README pattern, but
Important Files Changed
Sequence DiagramsequenceDiagram
participant OS as Android OS
participant OVP as OmiVoicePlaybackService
participant FCM as FCMNotificationService
participant Player as AudioPlayer
Note over OVP,Player: Voice playback active
OS->>FCM: onMessage (notification_type=plugin)
FCM->>OVP: isSpeaking?
OVP-->>FCM: true
FCM->>FCM: skip _showForegroundNotification
FCM->>FCM: _serverMessageStreamController.add()
OS->>OVP: interruptionEventStream begin+pause
OVP->>Player: pause()
OVP->>OVP: _pausedByInterruption = true
OS->>OVP: interruptionEventStream end+pause
OVP->>OVP: check _pausedByInterruption = true
OVP->>Player: play()
OVP->>OVP: _pausedByInterruption = false
Note over OVP,Player: Playback resumes seamlessly
|
| case AudioInterruptionType.pause: | ||
| if (_pausedByInterruption) { | ||
| _pausedByInterruption = false; | ||
| _player.play(); | ||
| } | ||
| break; |
There was a problem hiding this comment.
_player.play() in the end+pause branch is unawaited and has no error handling. If _clearInFlightState() was called between the begin and end events (or the player is in an idle/error state because the audio source was swapped), the call can throw an unhandled exception that silently disappears as an unawaited Future. Wrapping it in a try/catch keeps the handler robust.
| case AudioInterruptionType.pause: | |
| if (_pausedByInterruption) { | |
| _pausedByInterruption = false; | |
| _player.play(); | |
| } | |
| break; | |
| case AudioInterruptionType.pause: | |
| if (_pausedByInterruption) { | |
| _pausedByInterruption = false; | |
| try { | |
| _player.play(); | |
| } catch (_) {} | |
| } | |
| break; |
interrupt() already resets the flag; _clearInFlightState() (called from beginResponse() at the start of every new response) did not. If a notification interruption begins while the previous reply is playing and the user immediately asks a new question, the queues get cleared but _pausedByInterruption stays true. When the OS later fires the end+pause event for the original interruption, _onInterruption sees the stale true and calls _player.play() against the new session — either double-starts a chunk or resumes against an empty player. Greptile P1.
ServerMessage.empty() returns id '0000' — every voice question's draft ServerMessage reuses this literal placeholder, so beginResponse is always called with messageId='0000'. The early-return in beginResponse fired on _activeMessageId == messageId without checking whether that response was actually still in flight. After the first response naturally finished, _activeMessageId stayed '0000' (only interrupt() clears it). The next question hit the early return → _spoken was never reset → still pointed at the previous response's text length. updateStreamingResponse then dropped every chunk via the (_spoken >= cleaned.length) guard, so no audio queued and the user heard silence. Tighten the guard with isSpeaking so the no-op only fires for genuinely re-entrant calls within an active response. Stale _activeMessageId left behind by a finished response now falls through to _clearInFlightState and a fresh _spoken = 0 setup. Pre-existing on main; surfaced by manual testing of this PR's branch on samsung S938B / Android 16.
Adds a single debugPrint inside _onInterruption that records begin/end, type, and the relevant state (activeMessageId, isSpeaking, pausedByInt). Will be dropped or moved behind a flag once the 2nd-question silence report is root-caused on samsung S938B / Android 16 — currently suspect a spurious AUDIOFOCUS_LOSS on the 2nd setActive(true) after the 1st response's setActive(false), which would call interrupt() and clear _activeMessageId before updateStreamingResponse arrives.
Summary
Resolves #7135 — voice response on Android cut off by the chat-reply notification chime, and (cascading from that) "voice stops playing after 1-2 questions, only notification delivered."
Two complementary commits, each independently revertable:
Commit 1 — interruption handler + audio attributes (
omi_voice_playback_service.dart)The previous interruption handler called
interrupt()on everyinterruptionEventStreamend event, regardless ofevent.type. A notification chime triggers a transient pause-style focus loss; the end event then nuked the queue, cleared_activeMessageId, and deactivated the session. SubsequentupdateStreamingResponsecalls dropped at the activeId check. Repeat across 1-2 questions → user perceives "voice broken."Fix follows the audio_session README pattern (and just_audio's own internal handler):
AudioPlayer(handleInterruptions: false)— disable just_audio's built-in handler so it doesn't race with ours_onInterruptionbranches onevent.type:_player.pause()+ remember we pausedinterrupt()(permanent loss only)_player.play()if we pausedAudioSessionConfiguration.speech()with an explicit config:androidAudioAttributes.usage = USAGE_ASSISTANT(was MEDIA — semantically correct for assistant feedback; affects audio policy treatment of notification ducking)androidWillPauseWhenDucked = false(let OS duck volume during a brief notification rather than collapsing duck into pause)avAudioSessionMode = voicePrompt(Apple's recommended TTS mode; was spokenAudio which is for podcasts)becomingNoisyEventStreamfull-stop preserved (intentional — don't blast reply out of the phone speaker after unplugging headphones)Commit 2 — suppress chat-reply foreground notification while speaking (
notification_service_fcm.dart)The most common bug trigger: the backend sends an FCM with
notification_type=pluginfor every AI chat reply (backend/utils/chat.py:246, 374). On Android in foreground we re-display it via_showForegroundNotificationusing the default channel sound — that chime competes with the in-flight voice playback for audio focus.Skip the local notification when
OmiVoicePlaybackService.instance.isSpeaking. The chat-message stream still adds the message to the chat UI; the spoken reply is the audible signal — no double-ding.Cross-platform behavior
_shouldShowForegroundNotificationOnFCMMessageReceived()returns Android only, and FlutterFire'swillPresentdefault isUNNotificationPresentationOptionNoneso iOS doesn't display foreground FCMsTo fully silence iOS background notifications during voice playback we'd need a backend change to drop
aps.soundfrom chat-reply pushes — out of scope for this bug; flag if iOS background reports come in.Research evidence
just_audio-0.9.46/lib/just_audio.dart:302-348) implements the same shape — pause/duck/unknown distinct, only resume on pause-end, never on unknownAudioSessionConfiguration.speech()source (audio_session-0.1.25/lib/src/core.dart:561-571) confirms it setsusage: mediaandwillPauseWhenDucked: true— both wrong for assistant feedbackUSAGE_ASSISTANTis documented as the correct attribute for "voice control feedback, hot-word detection, prompts and responses for voice queries"UNNotificationPresentationOptionNone(firebase_messaging-15.2.10/ios/.../FLTFirebaseMessagingPlugin.m:343) — confirms iOS foreground silence is the defaultTest plan