SCStreamOutputType.audio delivers correctly-formed but all-zero PCM for VoIP background audio

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 ✗

Does iOS 27 ScreenCaptureKit intentionally exclude communications/VoIP audio from .audio capture? I am pretty sure the macOS version can do this.

I ran my app through "Designed for iPad" compatibility mode, on macOS 27. It does not record Zoom audio there either. Even though it is using the new ScreenCaptureKit made to do this for iOS 27.

I had my local mic off, and I could hear the remote person in the Zoom meeting thru my speakers. No text was transcribed, no audio packets were detected. At least on the iPhone there were packets, though silent.

This is the log file.

[2:53:55 PM] bluedotApp: scenePhase changed to inactive [2:53:55 PM] bluedotApp: scenePhase changed to active [2:54:16 PM] AudioRecorderService: startRecording succeeded, engine.isRunning=true [2:54:16 PM] AudioRecorderService: sink received its first buffer since start/resume. [2:54:24 PM] RecordingViewModel: stopRecording, pausedIngestCount=0, pausedMicIngestCount=0 [2:54:24 PM] AudioRecorderService: stopRecording called, externalBufferCount=0, pendingExternalBufferCount=0, externalPlayer.isPlaying=nil [2:54:24 PM] TranscriptionService: finish() called, successfulIngestCount=784 [2:55:13 PM] AudioRecorderService: startRecording succeeded, engine.isRunning=true [2:55:13 PM] AudioRecorderService: sink received its first buffer since start/resume. [2:55:33 PM] RecordingViewModel: stopRecording, pausedIngestCount=0, pausedMicIngestCount=0 [2:55:33 PM] AudioRecorderService: stopRecording called, externalBufferCount=0, pendingExternalBufferCount=0, externalPlayer.isPlaying=nil [2:55:33 PM] TranscriptionService: finish() called, successfulIngestCount=1839 [2:57:42 PM] AudioRecorderService: startRecording succeeded, engine.isRunning=true [2:57:42 PM] AudioRecorderService: sink received its first buffer since start/resume. [2:57:54 PM] ScreenCaptureService: filter.isMicrophoneEnabled=false [2:57:54 PM] ScreenCaptureService: microphone not enabled in picker — skipping .microphone output. Toggle it on in the picker sheet to test mic capture. [2:57:54 PM] ScreenCaptureService: startStream succeeded. [2:59:46 PM] ScreenCaptureService: stopCapture called, audioSampleCount=0, micSampleCount=0, isCapturing=true [2:59:46 PM] RecordingViewModel: stopRecording, pausedIngestCount=0, pausedMicIngestCount=0 [2:59:46 PM] AudioRecorderService: stopRecording called, externalBufferCount=0, pendingExternalBufferCount=0, externalPlayer.isPlaying=nil [2:59:46 PM] TranscriptionService: finish() called, successfulIngestCount=11554 [3:09:39 PM] AudioRecorderService: startRecording succeeded, engine.isRunning=true [3:09:39 PM] AudioRecorderService: sink received its first buffer since start/resume. [3:09:58 PM] ScreenCaptureService: filter.isMicrophoneEnabled=false [3:09:58 PM] ScreenCaptureService: microphone not enabled in picker — skipping .microphone output. Toggle it on in the picker sheet to test mic capture. [3:09:58 PM] ScreenCaptureService: startStream succeeded. [3:14:04 PM] ScreenCaptureService: stopCapture called, audioSampleCount=0, micSampleCount=0, isCapturing=true

SCStreamOutputType.audio delivers correctly-formed but all-zero PCM for VoIP background audio
 
 
Q