Speaking While Muted

Some calling apps show an indicator when the user is trying to speak while muted. This is helpful for users that forgot to turn on their microphone before they start talking.

The StreamVideo SDK detects this locally through the active WebRTC audio device and exposes the result on CallState. You can observe it from any SwiftUI view and render whatever UI fits your app.

Observing the state

The published property lives on call.state:

@Published public internal(set) var isSpeakingWhileMuted: Bool

It becomes true while the local user is talking with the microphone muted, and resets to false as soon as they stop or unmute. It also stays false when muted-speech detection is unsupported by the runtime, has been disabled, or the microphone is already unmuted, so you do not need to gate it on callSettings.audioOn yourself.

Showing a notification

The recommended pattern is to attach a ViewModifier to your call view that subscribes to the publisher and renders an overlay for a short period whenever the value flips to true.

import Combine
import StreamVideo
import StreamVideoSwiftUI
import SwiftUI

struct SpeakingWhileMutedViewModifier: ViewModifier {

    @Injected(\.colors) var colors
    @ObservedObject var viewModel: CallViewModel

    @State private var mutedIndicatorShown = false
    @State private var mutedIndicatorPresentationID = UUID()

    func body(content: Content) -> some View {
        content
            .onReceive(speakingWhileMutedPublisher) { isSpeakingWhileMuted in
                guard isSpeakingWhileMuted else { return }
                showMutedIndicator()
            }
            .overlay(overlayView)
    }

    @ViewBuilder
    private var overlayView: some View {
        if mutedIndicatorShown {
            VStack {
                Spacer()
                Text("You are muted. Unmute to speak.")
                    .padding(8)
                    .background(Color(UIColor.systemBackground))
                    .foregroundColor(colors.text)
                    .cornerRadius(16)
                    .padding()
            }
        }
    }

    private var speakingWhileMutedPublisher: AnyPublisher<Bool, Never> {
        guard let call = viewModel.call else {
            return Empty().eraseToAnyPublisher()
        }

        return call
            .state
            .$isSpeakingWhileMuted
            .removeDuplicates()
            .eraseToAnyPublisher()
    }

    private func showMutedIndicator() {
        guard !mutedIndicatorShown else { return }

        let presentationID = UUID()
        mutedIndicatorShown = true
        mutedIndicatorPresentationID = presentationID

        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            guard mutedIndicatorPresentationID == presentationID else { return }
            mutedIndicatorShown = false
        }
    }
}

A few notes on the implementation:

  • removeDuplicates() keeps the modifier from firing on every state republish, so the overlay only re-appears on a fresh muted-speech event.
  • The presentationID guard prevents an in-flight dismissal from hiding a notification that has just been shown again.
  • The overlay text mirrors the prompt used by Stream's reference apps. Replace it with localized copy or your own component if needed.

Wiring it into the call view

Apply the modifier wherever you render your call UI. The most common spot is on the inner call view returned from your ViewFactory:

struct CustomCallView<Factory: ViewFactory>: View {

    var viewFactory: Factory
    @ObservedObject var viewModel: CallViewModel

    var body: some View {
        CallView(viewFactory: viewFactory, viewModel: viewModel)
            .modifier(SpeakingWhileMutedViewModifier(viewModel: viewModel))
    }
}

Then expose it through your factory:

func makeCallView(viewModel: CallViewModel) -> some View {
    CustomCallView(viewFactory: self, viewModel: viewModel)
}

That's all that is required. The SDK handles the audio analysis and lifecycle: the publisher emits false automatically when the call ends or when the user unmutes, so your overlay does not need any explicit cleanup.