Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions ios/RCTWebRTC/ScreenAudioCapture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
//
// Copyright © 2026 Stream.io Inc. All rights reserved.
//

import AVFoundation
import Foundation

/// A length-prefixed binary frame is used (not `CFHTTPMessage`) because audio
/// frames are small and high-frequency: a single socket read routinely contains
/// several whole frames plus a partial one, which a header-terminated format
/// cannot reassemble without losing/corrupting the surplus bytes.
///
/// Layout (little-endian), fixed 24-byte header followed by `payloadLength` PCM bytes:
/// ```
/// offset size field
/// 0 4 magic = 0x53534155 ("SSAU")
/// 4 8 sampleRate (Float64 bit pattern)
/// 12 2 channels (UInt16)
/// 14 2 bitsPerChannel (UInt16)
/// 16 1 isFloat (UInt8: 1/0)
/// 17 1 isInterleaved (UInt8: 1/0)
/// 18 2 reserved
/// 20 4 payloadLength (UInt32)
/// 24 … interleaved PCM body
/// ```
enum ScreenAudioWireFormat {
static let magic: UInt32 = 0x5353_4155 // "SSAU"
static let headerSize = 24
}

@objc public final class ScreenAudioCapture: NSObject {

private static let audioSocketFileName = "rtc_SSFD_audio"
private static let appGroupInfoPlistKey = "RTCAppGroupIdentifier"

private static let readChunkSize = 16 * 1024
/// Safety cap: if the accumulator ever grows past this without yielding a
/// valid frame, drop it to resync (guards against corruption/desync).
private static let maxAccumulatorBytes = 1 * 1024 * 1024

@objc public var onAudioBuffer: ((AVAudioPCMBuffer) -> Void)?

private var connection: SocketConnection?
private var accumulator = Data()

@objc override public init() {
super.init()
}

deinit {
stop()
}

@objc public func start() {
guard let path = Self.audioSocketPath() else { return }

let connection = SocketConnection(filePath: path)
self.connection = connection
accumulator.removeAll(keepingCapacity: true)
connection.open(with: self)
}
Comment on lines +54 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

start() doesn't close a prior connection before replacing it.

If start() is called again while connection is already active (no intervening stop()), the old SocketConnection is dropped without calling close(), potentially leaking the open stream/socket and its run-loop scheduling.

🩹 Suggested guard
 `@objc` public func start() {
     guard let path = Self.audioSocketPath() else { return }
-    
+    stop()
     let connection = SocketConnection(filePath: path)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@objc public func start() {
guard let path = Self.audioSocketPath() else { return }
let connection = SocketConnection(filePath: path)
self.connection = connection
accumulator.removeAll(keepingCapacity: true)
connection.open(with: self)
}
`@objc` public func start() {
guard let path = Self.audioSocketPath() else { return }
stop()
let connection = SocketConnection(filePath: path)
self.connection = connection
accumulator.removeAll(keepingCapacity: true)
connection.open(with: self)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/RCTWebRTC/ScreenAudioCapture.swift` around lines 54 - 61, Update start()
to close the existing connection before assigning a new SocketConnection.
Preserve the current setup flow, but invoke close() on the prior connection
whenever one is present, then replace it and open the new connection.


@objc public func stop() {
connection?.close()
connection = nil
accumulator.removeAll(keepingCapacity: false)
}

// MARK: - Private

private static func audioSocketPath() -> String? {
guard let appGroup = Bundle.main.object(forInfoDictionaryKey: appGroupInfoPlistKey) as? String,
!appGroup.isEmpty,
let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
else {
return nil
}
return container.appendingPathComponent(audioSocketFileName).path
}

private func readBytes(from stream: InputStream) {
guard stream.hasBytesAvailable else { return }

var chunk = [UInt8](repeating: 0, count: Self.readChunkSize)
let numberOfBytesRead = stream.read(&chunk, maxLength: Self.readChunkSize)
guard numberOfBytesRead > 0 else { return }

accumulator.append(contentsOf: chunk[0..<numberOfBytesRead])
drainFrames()

if accumulator.count > Self.maxAccumulatorBytes {
NSLog("[SSAudio][Recv] accumulator overflow (%d bytes) — resetting to resync", accumulator.count)
accumulator.removeAll(keepingCapacity: true)
}
Comment on lines +91 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Overflow-reset threshold can drop an in-flight max-size legitimate frame before it completes.

payloadLength is accepted up to maxAccumulatorBytes (Line 112), so totalLength can reach headerSize + maxAccumulatorBytes. But the overflow guard in readBytes (Line 91) resets the accumulator once accumulator.count > maxAccumulatorBytes, which is reached before such a frame can ever fully arrive — the safety net wipes a legitimate frame instead of just corrupt data. Given the doc comment states frames are small and high-frequency, this is unlikely to trigger in practice, but the bound is inconsistent with the stated invariant.

🩹 Proposed fix
-            guard payloadLength > 0, payloadLength <= Self.maxAccumulatorBytes else {
+            guard payloadLength > 0, payloadLength <= Self.maxAccumulatorBytes - headerSize else {

Also applies to: 110-118

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/RCTWebRTC/ScreenAudioCapture.swift` around lines 91 - 94, Adjust the
overflow guard in readBytes to account for the complete frame size, including
headerSize, so frames with payloadLength up to maxAccumulatorBytes can finish
without being reset. Keep the payload-length validation in readBytes unchanged
and retain the existing reset behavior only for accumulators exceeding the
corrected total bound.

}

/// Parses as many complete frames as the accumulator currently holds.
private func drainFrames() {
let headerSize = ScreenAudioWireFormat.headerSize

while accumulator.count >= headerSize {
let base = accumulator.startIndex

// Resync on magic mismatch by dropping one byte at a time.
guard accumulator.leUInt32(0) == ScreenAudioWireFormat.magic else {
accumulator.removeFirst(1)
continue
}

let payloadLength = Int(accumulator.leUInt32(20))
let totalLength = headerSize + payloadLength
guard payloadLength > 0, payloadLength <= Self.maxAccumulatorBytes else {
// Corrupt length — drop the magic and resync.
accumulator.removeFirst(1)
continue
}
// Wait for the rest of the frame.
if accumulator.count < totalLength { return }

let sampleRate = Double(bitPattern: accumulator.leUInt64(4))
let channels = UInt32(accumulator.leUInt16(12))
let bits = Int(accumulator.leUInt16(14))
let isFloat = accumulator[base + 16] != 0
let isInterleaved = accumulator[base + 17] != 0
let payload = accumulator.subdata(in: (base + headerSize)..<(base + totalLength))

accumulator.removeFirst(totalLength)

if let buffer = makePCMBuffer(payload: payload,
sampleRate: sampleRate,
channels: channels,
bits: bits,
isFloat: isFloat,
isInterleaved: isInterleaved) {
onAudioBuffer?(buffer)
}
}
}

/// Rebuilds an `AVAudioPCMBuffer` from a raw PCM payload + format fields.
private func makePCMBuffer(payload: Data,
sampleRate: Double,
channels: UInt32,
bits: Int,
isFloat: Bool,
isInterleaved: Bool) -> AVAudioPCMBuffer? {
guard sampleRate > 0, channels > 0, !payload.isEmpty else {
NSLog("[SSAudio][Recv] makePCMBuffer FAILED: bad header sr=%.0f ch=%d bytes=%d", sampleRate, channels, payload.count)
return nil
}

let commonFormat: AVAudioCommonFormat
if isFloat, bits == 32 {
commonFormat = .pcmFormatFloat32
} else if !isFloat, bits == 16 {
commonFormat = .pcmFormatInt16
} else {
NSLog("[SSAudio][Recv] makePCMBuffer FAILED: unsupported format isFloat=%d bits=%d", isFloat ? 1 : 0, bits)
return nil
}

guard let format = AVAudioFormat(
commonFormat: commonFormat,
sampleRate: sampleRate,
channels: AVAudioChannelCount(channels),
interleaved: isInterleaved
) else {
NSLog("[SSAudio][Recv] makePCMBuffer FAILED: AVAudioFormat nil")
return nil
}

let bytesPerFrame = Int(format.streamDescription.pointee.mBytesPerFrame)
guard bytesPerFrame > 0 else { return nil }

let frameCount = AVAudioFrameCount(payload.count / bytesPerFrame)
guard frameCount > 0,
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
NSLog("[SSAudio][Recv] makePCMBuffer FAILED: frameCount=%d bytesPerFrame=%d bytes=%d", frameCount, bytesPerFrame, payload.count)
return nil
}
buffer.frameLength = frameCount

// Copy the interleaved PCM body into the buffer's backing storage.
let bufferList = UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList)
guard let dest = bufferList.first?.mData else { return nil }
let bytesToCopy = min(Int(bufferList[0].mDataByteSize), payload.count)
payload.withUnsafeBytes { raw in
if let src = raw.baseAddress {
memcpy(dest, src, bytesToCopy)
}
}

return buffer
}
Comment on lines +141 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does AVAudioPCMBuffer store non-interleaved multi-channel PCM as separate AudioBuffers per channel in mutableAudioBufferList?

💡 Result:

Yes, AVAudioPCMBuffer stores non-interleaved (de-interleaved) multi-channel PCM audio as separate AudioBuffers within its underlying AudioBufferList [1][2][3]. When you create an AVAudioPCMBuffer with a non-interleaved format, the object manages an AudioBufferList where the number of buffers (mNumberBuffers) corresponds to the number of channels [1][4]. Each of these individual AudioBuffers contains the sample data for a single channel [1]. In contrast, if the format is interleaved, the AudioBufferList typically contains only one AudioBuffer (mNumberBuffers = 1) that holds all interleaved channels of data [5][1][4]. The mutableAudioBufferList property provides access to this underlying structure [2][3]. When using it, it is important to understand the relationship between the buffer's format and the structure of the list: 1. De-interleaved (Non-interleaved): mNumberBuffers equals the channel count. Each AudioBuffer represents one channel [1][4]. 2. Interleaved: mNumberBuffers is 1. That single buffer contains all interleaved channels [5][1][4]. When modifying the underlying buffers via mutableAudioBufferList, developers must ensure that the mDataByteSize fields correctly reflect the intended capacity or length, and maintain consistency with the buffer's frameLength [1][2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate ScreenAudioCapture.swift =="
fd -a 'ScreenAudioCapture.swift' . || true

echo "== relevant file stats =="
file="$(fd 'ScreenAudioCapture.swift' . | head -n1)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  echo "== outline =="
  ast-grep outline "$file" --view expanded || true
  echo "== lines 1-230 =="
  cat -n "$file" | sed -n '1,230p'
fi

echo "== isInterleaved usages =="
rg -n "isInterleaved|makePCMBuffer|AudioBuffer|AVAudioPCMBuffer|mutableAudioBufferList" "$file" || true

Repository: GetStream/react-native-webrtc

Length of output: 12511


🌐 Web query:

Apple docs AVAudioStreamBasicDescription mBytesPerFrame non-interleaved format

💡 Result:

In an AVAudioStreamBasicDescription (ASBD), the mBytesPerFrame field represents the number of bytes required to hold one audio frame [1][2]. When the kAudioFormatFlagIsNonInterleaved flag is set, the interpretation of the per-frame fields changes [3][2]. In non-interleaved format, the ASBD describes the layout of a single buffer, which contains exactly one channel of audio data [3][2][4]. Consequently, mBytesPerFrame represents the number of bytes in one frame of that single channel [3][2]. This is in contrast to interleaved format, where the per-frame fields describe the combined frame consisting of all n channels [5][2]. To calculate mBytesPerFrame for a non-interleaved PCM format: mBytesPerFrame = (mBitsPerChannel / 8) [6][4] In this configuration, even if the total stream has multiple channels, each individual buffer in the associated AudioBufferList contains only one channel of data [3][2]. The total number of channels in the stream is still indicated by mChannelsPerFrame [3]. Therefore, in a non-interleaved context, the value of mBytesPerFrame will reflect the size of a single sample (or the bytes per frame for one channel) rather than the sum of all channels [3][2][4].

Citations:


Handle non-interleaved payloads before copying into AVAudioPCMBuffer.

isInterleaved is part of the wire format, but makePCMBuffer only copies from bufferList.first; for non-interleaved multi-channel formats, mutableAudioBufferList contains one AudioBuffer per channel, and mBytesPerFrame describes one channel, so frameCount = payload.count / bytesPerFrame inflates frames and the remaining channels stay silent. Add planar copy handling, or restrict isInterleaved to a single interleaved-only caller if that format is not intended for multi-channel.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/RCTWebRTC/ScreenAudioCapture.swift` around lines 141 - 194, Update
makePCMBuffer to handle non-interleaved multi-channel payloads instead of always
copying into bufferList.first. For isInterleaved == false, calculate frame count
using the complete per-frame payload layout and distribute each channel’s
samples across the corresponding AudioBuffer; preserve the existing
single-buffer copy path for interleaved audio and reject malformed payload sizes
safely.

}

extension ScreenAudioCapture: StreamDelegate {
public func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
switch eventCode {
case .hasBytesAvailable:
if let input = aStream as? InputStream {
readBytes(from: input)
}
case .endEncountered:
stop()
case .errorOccurred:
NSLog("[SSAudio][Recv] audio socket stream error: %@", aStream.streamError?.localizedDescription ?? "")
default:
break
}
}
}
Comment on lines +197 to +212

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

.errorOccurred doesn't tear down the connection.

On .hasBytesAvailable and .endEncountered the code reacts appropriately, but .errorOccurred only logs — the socket/stream and its resources are left open with no cleanup or recovery path. Consider calling stop() here as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/RCTWebRTC/ScreenAudioCapture.swift` around lines 197 - 212, Update
ScreenAudioCapture.stream(_:handle:) so the .errorOccurred case calls stop()
after logging the stream error, ensuring the audio socket and related resources
are torn down consistently with .endEncountered.


// MARK: - Little-endian Data readers

private extension Data {
func leUInt16(_ offset: Int) -> UInt16 {
let s = startIndex + offset
return UInt16(self[s]) | (UInt16(self[s + 1]) << 8)
}

func leUInt32(_ offset: Int) -> UInt32 {
let s = startIndex + offset
return UInt32(self[s])
| (UInt32(self[s + 1]) << 8)
| (UInt32(self[s + 2]) << 16)
| (UInt32(self[s + 3]) << 24)
}

func leUInt64(_ offset: Int) -> UInt64 {
let s = startIndex + offset
var value: UInt64 = 0
for i in 0..<8 {
value |= UInt64(self[s + i]) << (8 * i)
}
return value
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ final class AudioRingBuffer {
return toRead
}

/// Drops the oldest samples so that at most `maxFrames` remain buffered.
/// Bounds end-to-end latency and prevents the buffer from pinning full when
/// the producer runs slightly ahead of the consumer (clock drift).
/// - Returns: the number of frames dropped.
@discardableResult
func trim(toMaxFrames maxFrames: Int) -> Int {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
let avail = _availableToRead
guard avail > maxFrames else { return 0 }
let toDrop = avail - maxFrames
readPos = (readPos + toDrop) % capacity
return toDrop
}

// MARK: - Reset

/// Clears all buffered data. Call when not concurrently accessed by both
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ import WebRTC
/// AVAudioConverter produces normalized Float32, so we must scale up.
private static let floatS16Scale: Float = 32768.0

/// Max screen-audio backlog kept in the ring buffer, in seconds. Bounds
/// end-to-end latency while leaving enough cushion to absorb socket delivery
/// bursts and producer/consumer clock jitter.
private static let maxBufferedLatencySeconds = 0.1

/// Guards the one-time "mic not capturing" warning.
private var loggedNoTargetFormat = false

// MARK: - RTCAudioCustomProcessingDelegate

/// Called by WebRTC when the processing pipeline initializes or reconfigures.
Expand Down Expand Up @@ -86,6 +94,7 @@ import WebRTC
guard !isMixing else { return }
ringBuffer.reset()
isMixing = true
loggedNoTargetFormat = false
}

/// Stop audio mixing.
Expand All @@ -96,28 +105,54 @@ import WebRTC
audioConverter.reset()
}

/// Receive a screen audio CMSampleBuffer from InAppScreenCapturer.
/// Converts to the processing format and writes to the ring buffer.
@objc public func enqueue(_ sampleBuffer: CMSampleBuffer) {
guard isMixing, let targetFmt = targetFormat else { return }
guard isMixing else { return }
guard let pcmBuffer = audioConverter.pcmBuffer(from: sampleBuffer) else { return }
enqueue(pcmBuffer, trimBacklog: false)
}

@objc public func enqueuePCM(_ pcmBuffer: AVAudioPCMBuffer) {
enqueue(pcmBuffer, trimBacklog: true)
}

guard let pcm = audioConverter.pcmBuffer(from: sampleBuffer) else { return }
/// Shared worker: converts `pcmBuffer` to the processing format, drops silent
/// buffers, and writes it to the ring buffer.
/// - Parameter trimBacklog: when `true` (default), caps the ring backlog to
/// `maxBufferedLatencySeconds` by dropping the oldest samples.
private func enqueue(_ pcmBuffer: AVAudioPCMBuffer, trimBacklog: Bool = true) {
guard isMixing else { return }
guard let targetFmt = targetFormat else {
if !loggedNoTargetFormat {
loggedNoTargetFormat = true
NSLog("[SSAudio][Mixer] enqueuePCM dropped: targetFormat nil (APM not initialized — is the mic capturing?)")
}
return
}

let buffer: AVAudioPCMBuffer
if pcm.format.sampleRate != targetFmt.sampleRate
|| pcm.format.channelCount != targetFmt.channelCount
|| pcm.format.commonFormat != targetFmt.commonFormat
|| pcm.format.isInterleaved != targetFmt.isInterleaved {
guard let converted = audioConverter.convertIfRequired(pcm, to: targetFmt) else { return }
if pcmBuffer.format.sampleRate != targetFmt.sampleRate
|| pcmBuffer.format.channelCount != targetFmt.channelCount
|| pcmBuffer.format.commonFormat != targetFmt.commonFormat
|| pcmBuffer.format.isInterleaved != targetFmt.isInterleaved {
guard let converted = audioConverter.convertIfRequired(pcmBuffer, to: targetFmt) else { return }
buffer = converted
} else {
buffer = pcm
buffer = pcmBuffer
}

if ScreenShareAudioConverter.isSilent(buffer) { return }

guard let channelData = buffer.floatChannelData else { return }

ringBuffer.write(channelData[0], count: Int(buffer.frameLength))
Comment on lines 143 to 147

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate file =="
fd -a 'ScreenShareAudioMixer.swift' . || true

file="$(fd 'ScreenShareAudioMixer.swift' . | head -n1 || true)"
if [ -n "${file:-}" ]; then
  echo "== File: $file =="
  wc -l "$file"
  echo "== Relevant excerpts =="
  sed -n '1,220p' "$file" | cat -n
fi

echo "== Search related symbols =="
rg -n "class ScreenShareAudioMixer|struct ScreenShareAudioMixer|func enqueuePCM|ScreenShareAudioConverter|ScreenAudioCapture|ringBuffer|mixFromRingBuffer|ScreenShareAudioMixer.swift" . -S || true

Repository: GetStream/react-native-webrtc

Length of output: 11180


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'ScreenAudioCapture.swift' . | head -n1 || true)"
if [ -n "${file:-}" ]; then
  echo "== File: $file =="
  wc -l "$file"
  echo "== Relevant excerpts =="
  sed -n '1,260p' "$file" | cat -n
fi

echo "== Audio ring buffer references =="
fd 'AudioRingBuffer.swift' . | while read -r f; do
  echo "== File: $f =="
  wc -l "$f"
  sed -n '1,220p' "$f" | cat -n
done

Repository: GetStream/react-native-webrtc

Length of output: 16206


Downmix screen audio before writing to the mono ring buffer.

ScreenAudioCapture preserves multi-channel PCM, the ring buffer is mono, and enqueue writes only channelData[0] while mixFromRingBuffer copies that single stream to every output channel. Averaging channels into the ring buffer keeps audible screen content from channels that might otherwise be dropped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ios/RCTWebRTC/Utils/AudioDeviceModule/ScreenShare/ScreenShareAudioMixer.swift`
around lines 143 - 147, Update enqueue to downmix all channels in buffer into a
mono sample stream before ringBuffer.write, averaging corresponding samples
across the available channels rather than writing only channelData[0]. Preserve
the existing silence check and frame count, and keep mixFromRingBuffer’s
mono-to-output-channel behavior unchanged.


if trimBacklog {
// Bound backlog to ~maxBufferedLatencySeconds by dropping oldest, so the
// buffer never pins full (which caused ~1s latency + dropped writes when
// the producer runs slightly ahead of the consumer).
let maxFrames = Int(processingSampleRate * Self.maxBufferedLatencySeconds)
ringBuffer.trim(toMaxFrames: maxFrames)
}
}

// MARK: - Private mixing
Expand Down
Loading