Skip to content

fix(app): voice response no longer cut off by notification chime (#7135)#7180

Merged
mdmohsin7 merged 5 commits intomainfrom
caleb/voice-playback-interruption-fix
May 6, 2026
Merged

fix(app): voice response no longer cut off by notification chime (#7135)#7180
mdmohsin7 merged 5 commits intomainfrom
caleb/voice-playback-interruption-fix

Conversation

@mdmohsin7
Copy link
Copy Markdown
Member

@mdmohsin7 mdmohsin7 commented May 5, 2026

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 every interruptionEventStream end event, regardless of event.type. A notification chime triggers a transient pause-style focus loss; the end event then nuked the queue, cleared _activeMessageId, and deactivated the session. Subsequent updateStreamingResponse calls 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
  • _onInterruption branches on event.type:
    • begin+pause → _player.pause() + remember we paused
    • begin+duck → no-op (OS handles ducking)
    • begin+unknown → interrupt() (permanent loss only)
    • end+pause → _player.play() if we paused
    • end+duck → no-op
    • end+unknown → no-op (already cleared on begin)
  • Replaced AudioSessionConfiguration.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)
  • becomingNoisyEventStream full-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=plugin for every AI chat reply (backend/utils/chat.py:246, 374). On Android in foreground we re-display it via _showForegroundNotification using 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

Platform / state Bug? Fix
Android foreground Yes (reported) Both commits — eliminates Omi's own chime + survives any other app's chime
Android background N/A — voice playback is foreground-only in our flow
iOS foreground No — _shouldShowForegroundNotificationOnFCMMessageReceived() returns Android only, and FlutterFire's willPresent default is UNNotificationPresentationOptionNone so iOS doesn't display foreground FCMs Commit 2 gate is harmless no-op; commit 1 still benefits any other app's notification
iOS background APNS displays directly; can't suppress client-side. iOS audio policy ducks our voice during chime; commit 1 ensures clean resume Commit 1 only

To fully silence iOS background notifications during voice playback we'd need a backend change to drop aps.sound from chat-reply pushes — out of scope for this bug; flag if iOS background reports come in.

Research evidence

  • audio_session README's recommended interruption handler shape: https://github.com/ryanheise/audio_session/blob/master/README.md#reacting-to-audio-interruptions
  • just_audio's own internal handler (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 unknown
  • AudioSessionConfiguration.speech() source (audio_session-0.1.25/lib/src/core.dart:561-571) confirms it sets usage: media and willPauseWhenDucked: true — both wrong for assistant feedback
  • Android USAGE_ASSISTANT is documented as the correct attribute for "voice control feedback, hot-word detection, prompts and responses for voice queries"
  • FlutterFire iOS willPresent default returns UNNotificationPresentationOptionNone (firebase_messaging-15.2.10/ios/.../FLTFirebaseMessagingPlugin.m:343) — confirms iOS foreground silence is the default

Test plan

  • Manual on Android (samsung S938B / 16 if available, otherwise generic emulator): set Voice Response to "Always". Ask Omi a question, let voice reply start. Confirm no chat-reply notification chime fires; voice plays fully.
  • Manual on Android: with Voice Response Always, ask 5+ questions in a row. Confirm voice still plays on every reply (no degradation).
  • Manual on Android: while voice is playing, send a WhatsApp / SMS / any third-party notification. Confirm voice pauses briefly (or ducks) and resumes instead of being killed.
  • Manual on Android: while voice is playing, unplug Bluetooth headphones. Confirm voice stops fully (existing intentional behavior preserved).
  • Manual on iOS: set Voice Response to "Always". Ask Omi a question. Confirm voice plays fully and no notification banner/sound appears in foreground.
  • Manual on iOS: while voice is playing in app foreground, trigger a Slack/iMessage chime. Confirm voice ducks and resumes.

mdmohsin7 added 2 commits May 5, 2026 14:49
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
@mdmohsin7 mdmohsin7 added app bug Something isn't working p2 Priority: Important (score 14-21) labels May 5, 2026
@mdmohsin7
Copy link
Copy Markdown
Member Author

@morpheus review — Approved

Well-researched fix. 2 atomic commits (single file each), independently revertable.

Commit 1 — Interruption handler:
Confirmed the root cause on main: interrupt() fired on every end event regardless of type, nuking the queue after transient notification chimes. Fix correctly branches on event.type per the audio_session README pattern:

  • duck begin/end → no-op (OS handles; androidWillPauseWhenDucked: false ensures no pause collapse)
  • pause begin → _player.pause() + flag; pause end → resume if flagged
  • unknown begin → interrupt() (permanent loss); unknown end → no-op

AudioPlayer(handleInterruptions: false) prevents race with just_audio's internal handler. Audio attributes corrected (USAGE_ASSISTANT, voicePrompt, willPauseWhenDucked: false). becomingNoisyEventStream full-stop preserved. _pausedByInterruption reset in interrupt() prevents stale-flag resume.

Commit 2 — Notification gate:
Both notification sites (plugin + announcement) gated identically on isSpeaking. _shouldShowForegroundNotificationOnFCMMessageReceived() is Android-only so iOS is a harmless no-op. Eliminates the most common trigger while commit 1 handles third-party notifications.

Cross-platform analysis in PR body is solid. Needs device verification per test plan.

@mdmohsin7 mdmohsin7 marked this pull request as ready for review May 6, 2026 07:57
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 6, 2026

Greptile Summary

This PR fixes Android voice responses being cut off by notification chimes by overhauling the audio interruption handler in OmiVoicePlaybackService and suppressing foreground notifications while voice is actively playing.

  • omi_voice_playback_service.dart: Disables just_audio's built-in interruption handler (handleInterruptions: false) and replaces it with a custom _onInterruption that branches on AudioInterruptionType — duck events are ignored, pause events pause/resume cleanly via a _pausedByInterruption flag, and unknown events trigger a full interrupt(). The AudioSessionConfiguration is upgraded to use USAGE_ASSISTANT and voicePrompt mode.
  • notification_service_fcm.dart: Wraps both _showForegroundNotification call sites on Android with !OmiVoicePlaybackService.instance.isSpeaking, eliminating the self-inflicted chime from chat-reply FCMs during active playback while still delivering the message to the chat UI.

Confidence Score: 3/5

Safe to merge after addressing the stale _pausedByInterruption flag in _clearInFlightState(); the notification suppression change is straightforward and correct.

The core interruption logic is well-structured and follows the audio_session README pattern, but _clearInFlightState() — called by beginResponse() — does not reset _pausedByInterruption. If a second question arrives while an interruption is in progress, the stale true flag causes the end+pause event to call _player.play() on a freshly-initialized player that may not yet have audio loaded, potentially replaying a chunk or resuming unexpectedly.

omi_voice_playback_service.dart — specifically the _clearInFlightState() method and the unawaited _player.play() call in the interruption end handler.

Important Files Changed

Filename Overview
app/lib/services/voice_playback/omi_voice_playback_service.dart Adds proper audio interruption handling with type-based branching, disables just_audio's built-in handler, and upgrades AudioSession config — but _clearInFlightState() omits resetting _pausedByInterruption, leaving stale state when a new response begins during an active interruption.
app/lib/services/notifications/notification_service_fcm.dart Adds an isSpeaking guard around both foreground notification display paths on Android to prevent the chat-reply notification chime from competing with active voice playback; the server message stream is still delivered regardless.

Sequence Diagram

sequenceDiagram
    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
Loading

Comments Outside Diff (1)

  1. app/lib/services/voice_playback/omi_voice_playback_service.dart, line 245-253 (link)

    P1 _clearInFlightState() does not reset _pausedByInterruption, leaving it as stale true. If beginResponse() is called while an interruption is active (e.g. another question arrives mid-chime), _pausedByInterruption stays true after the queues are cleared and a new session starts. When the OS later fires the end+pause event, _onInterruption sees _pausedByInterruption == true and calls _player.play() — either doubling a chunk that is already playing or resuming a stopped/empty player unexpectedly.

Reviews (1): Last reviewed commit: "fix(app): suppress FCM foreground notifi..." | Re-trigger Greptile

Comment on lines +229 to +234
case AudioInterruptionType.pause:
if (_pausedByInterruption) {
_pausedByInterruption = false;
_player.play();
}
break;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 _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.

Suggested change
case AudioInterruptionType.pause:
if (_pausedByInterruption) {
_pausedByInterruption = false;
_player.play();
}
break;
case AudioInterruptionType.pause:
if (_pausedByInterruption) {
_pausedByInterruption = false;
try {
_player.play();
} catch (_) {}
}
break;

mdmohsin7 added 3 commits May 6, 2026 08:05
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.
@mdmohsin7 mdmohsin7 merged commit 02fa514 into main May 6, 2026
2 checks passed
@mdmohsin7 mdmohsin7 deleted the caleb/voice-playback-interruption-fix branch May 6, 2026 08:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app bug Something isn't working p2 Priority: Important (score 14-21)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Voice response: bug reports

1 participant