Is silently zeroing .audio output for communications/VoIP-category background app audio (while leaving frame delivery, timing, and format metadata intact) expected, documented behavior on iOS?
Is there an SCStreamConfiguration or SCContentFilter setting that unlocks this, or is this a known gap in the current iOS ScreenCaptureKit implementation relative to macOS (where .audio is documented/known to capture other apps' audio including calls)? A pointer to relevant documentation or a radar number would be very helpful.
My test app: Rabbler
Rabbler main use it turning voice to text transcriptions. ie. meeting notes. It works well using the iPhone mic or AirPods. It fails when Zoom or Teams meeting is joined. Audio is paused by a well handled interruption. But that yields lost transcription text.
Environment
iOS 27.0, physical device iPhone 15ProMax (not simulator)
Xcode 27.0 (Build 27A266a), iPhoneOS27.0 SDK
App target UIBackgroundModes: audio, screen-capture
Capture another app's audio via SCStream's .audio output type, using SCContentSharingPicker for source selection, per the "Capturing screen content on iOS" sample's picker pattern.
Setup (trimmed to the relevant parts)
var configuration = SCContentSharingPickerConfiguration()
configuration.showsMicrophoneControl = true
picker.defaultConfiguration = configuration
picker.add(self)
picker.isActive = true
picker.present()
// contentSharingPicker(_:didUpdateWith:for:) -> startStream(with:)
let config = SCStreamConfiguration()
config.capturesAudio = true
let newStream = SCStream(filter: filter, configuration: config, delegate: self)
try newStream.addStreamOutput(seldlerQueue: .main)
if filter.isMicrophoneEnabled {
try newStream.addStreamOutputsampleHandlerQueue: .main)
}
try await newStream.startCapture(
Sample-buffer handling, convertinmeasuring amplitude:
func stream(_ stream: SCStream, deBuffer: CMSampleBuffer, of type:SCStreamOutputType) {
guard sampleBuffer.isValid, tmicrophone else { return }
// ... (format captured once from first buffer via
CMAudioFormatDescriptionGetStream
let frameCount = AVAudioFrameCount(CMSampleBufferGetNumSamples(sampleBuffer))
let pcmBuffer = AVAudioPCMBufmeCapacity: frameCount)!
pcmBuffer.frameLength = frameCount
let status = CMSampleBufferCost(
sampleBuffer, at: 0, frameCount: Int32(frameCount), into:
pcmBuffer.mutableAudioBufferList
)
// status == noErr every time
}
Amplitude check on the resulting AVAudioPCMBuffer.floatChannelData:
var peak: Float = 0, sumSquares: 0
for channel in 0..<Int(buffer.format.channelCount) {
let samples = channelData[cha
for frame in 0..<frameCount { let sample = samples[fram
peak = max(peak, abs(sample)) sumSquares += sample * sa
if sample != 0 { nonZeroCount += 1 } }
}
Result 1 — Music app selected as capture source (baseline, works correctly)
Real audio content: writing the buffers straight to a .caf file via AVAudioFile produces a real, listenable ~9MB file matchihe capture duration (stereo, 48kHz, Float32). Confirmed by ear.
Result 2 — Zoom call selected as capture source (fails silently)
SCStream delivers buffers continuously and correctly-formed — same format every time (2 ch, 48000 Hz, Float32, deinterleaved)atus == noErr fromCMSampleBufferCopyPCMDataIntoAudioBufferList every time. But every sample is exactly zero:
[2:17:19 PM] ScreenCaptureService: .audio first buffer format — <AVAudioFormat 0x12063fb60: 2ch, 48000 Hz, Float32, deinterlea
[2:17:19 PM] ScreenCaptureService: .audio buffer #1 — peak=0.0 rms=0.0 nonZero=0/1920 [2:17:19 PM] ScreenCaptureService0.0 rms=0.0 nonZero=0/1920
... [2:17:21 PM] ScreenCaptureServicek=0.0 rms=0.0 nonZero=0/1920
[2:18:27 PM] ScreenCaptureService: .audio buffer #3400 — peak=0.0 rms=0.0 nonZero=0/1920
That's peak=0.0/rms=0.0/0 nonZero samples across every single one of 3400+ consecutive buffersover ~70 seconds of an active Zoo Writing these buffers to a .caffile produces a valid, correctly-sized, completely silent audio file — not corrupted, not empty, genuinely all zeros.
Control test — .microphone in theall
With filter.isMicrophoneEnabled = on the same SCStream, during thesame Zoom call, correctly captures the local user's own voice (confirmed by ear from the resulting file) — even though the call is silently holding exclusive access to the mic hardware from Rabbler's own AVAudioEngine.inputNode tap (which gets interrupted, as expected). This rules out a session-wide permission failure -.micophone clearly has real access to audio in this exact session; .audio does not, specifically for this source.
evidence now looks like this
iOS 27 ScreenCaptureKit
│
├── SomaFM playback
│ └── .audio → REAL PCM ✓
│
├── Zoom remote audio
│ └── .audio → ZERO PCM ✗
│
└── Teams remote audio
└── .audio → ZERO PCM ✗