-
Notifications
You must be signed in to change notification settings - Fork 3
feat: added audio capture for ios broadcast screensharing mode #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
|
|
||
| @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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
🩹 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 |
||
| } | ||
|
|
||
| /// 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 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" || trueRepository: GetStream/react-native-webrtc Length of output: 12511 🌐 Web query:
💡 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
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
On 🤖 Prompt for AI Agents |
||
|
|
||
| // 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 |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -86,6 +94,7 @@ import WebRTC | |
| guard !isMixing else { return } | ||
| ringBuffer.reset() | ||
| isMixing = true | ||
| loggedNoTargetFormat = false | ||
| } | ||
|
|
||
| /// Stop audio mixing. | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 || trueRepository: 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
doneRepository: GetStream/react-native-webrtc Length of output: 16206 Downmix screen audio before writing to the mono ring buffer.
🤖 Prompt for AI Agents |
||
|
|
||
| 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 | ||
|
|
||
There was a problem hiding this comment.
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 whileconnectionis already active (no interveningstop()), the oldSocketConnectionis dropped without callingclose(), 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
🤖 Prompt for AI Agents