forked from livekit/react-native-webrtc
-
Notifications
You must be signed in to change notification settings - Fork 2
feat: stereo audio output support #23
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
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
ca50af5
initialise peer factory with audio engine
santhoshvai c5325e4
137.1.0-alpha.1
santhoshvai a6f1c00
import audio device module
santhoshvai e2660d0
ios addition
santhoshvai d6b2e3c
update podspec
santhoshvai 9e4bd4e
remove extra space
santhoshvai 0802887
android support
santhoshvai b3e2446
add
santhoshvai 0777dda
fix warning
santhoshvai 68cc467
align config
santhoshvai 3dbc04a
137.1.0-alpha.2
santhoshvai 630250e
isVoiceProcessingBypassedSubject is set on init
santhoshvai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
573 changes: 573 additions & 0 deletions
573
ios/RCTWebRTC/Utils/AudioDeviceModule/AudioDeviceModule.swift
Large diffs are not rendered by default.
Oops, something went wrong.
122 changes: 122 additions & 0 deletions
122
ios/RCTWebRTC/Utils/AudioDeviceModule/AudioEngineLevelNodeAdapter.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| // | ||
| // Copyright © 2025 Stream.io Inc. All rights reserved. | ||
| // | ||
|
|
||
| import Accelerate | ||
| import AVFoundation | ||
| import Combine | ||
| import Foundation | ||
|
|
||
| protocol AudioEngineNodeAdapting { | ||
|
|
||
| var subject: CurrentValueSubject<Float, Never>? { get set } | ||
|
|
||
| func installInputTap( | ||
| on node: AVAudioNode, | ||
| format: AVAudioFormat, | ||
| bus: Int, | ||
| bufferSize: UInt32 | ||
| ) | ||
|
|
||
| func uninstall(on bus: Int) | ||
| } | ||
|
|
||
| /// Observes an `AVAudioMixerNode` and publishes decibel readings for UI and | ||
| /// analytics consumers. | ||
| final class AudioEngineLevelNodeAdapter: AudioEngineNodeAdapting { | ||
|
|
||
| enum Constant { | ||
| // The down limit of audio pipeline in DB that is considered silence. | ||
| static let silenceDB: Float = -160 | ||
| } | ||
|
|
||
| var subject: CurrentValueSubject<Float, Never>? | ||
|
|
||
| private var inputTap: AVAudioMixerNode? | ||
|
|
||
| /// Installs a tap on the supplied audio node to monitor input levels. | ||
| /// - Parameters: | ||
| /// - node: The node to observe; must be an `AVAudioMixerNode`. | ||
| /// - format: Audio format expected by the tap. | ||
| /// - bus: Output bus to observe. | ||
| /// - bufferSize: Tap buffer size. | ||
| func installInputTap( | ||
| on node: AVAudioNode, | ||
| format: AVAudioFormat, | ||
| bus: Int = 0, | ||
| bufferSize: UInt32 = 1024 | ||
| ) { | ||
| guard let mixer = node as? AVAudioMixerNode, inputTap == nil else { return } | ||
|
|
||
| mixer.installTap( | ||
| onBus: bus, | ||
| bufferSize: bufferSize, | ||
| format: format | ||
| ) { [weak self] buffer, _ in | ||
| self?.processInputBuffer(buffer) | ||
| } | ||
|
|
||
| inputTap = mixer | ||
| // log.debug("Input node installed", subsystems: .audioRecording) | ||
| } | ||
|
|
||
| /// Removes the tap and resets observed audio levels. | ||
| /// - Parameter bus: Bus to remove the tap from, defaults to `0`. | ||
| func uninstall(on bus: Int = 0) { | ||
| if let mixer = inputTap, mixer.engine != nil { | ||
| mixer.removeTap(onBus: 0) | ||
| } | ||
| subject?.send(Constant.silenceDB) | ||
| inputTap = nil | ||
| // log.debug("Input node uninstalled", subsystems: .audioRecording) | ||
| } | ||
|
|
||
| // MARK: - Private Helpers | ||
|
|
||
| /// Processes the PCM buffer produced by the tap and computes a clamped RMS | ||
| /// value which is forwarded to the publisher. | ||
| private func processInputBuffer(_ buffer: AVAudioPCMBuffer) { | ||
| // Safely unwrap the `subject` (used to publish updates) and the | ||
| // `floatChannelData` (pointer to the interleaved or non-interleaved | ||
| // channel samples in memory). If either is missing, exit early since | ||
| // processing cannot continue. | ||
| guard | ||
| let subject, | ||
| let channelData = buffer.floatChannelData | ||
| else { return } | ||
|
|
||
| // Obtain the total number of frames in the buffer as a vDSP-compatible | ||
| // length type (`vDSP_Length`). This represents how many samples exist | ||
| // per channel in the current audio buffer. | ||
| let frameCount = vDSP_Length(buffer.frameLength) | ||
|
|
||
| // Declare a variable to store the computed RMS (root-mean-square) | ||
| // amplitude value for the buffer. It will represent the signal's | ||
| // average power in linear scale (not decibels yet). | ||
| var rms: Float = 0 | ||
|
|
||
| // Use Apple's Accelerate framework to efficiently compute the RMS | ||
| // (root mean square) of the float samples in the first channel. | ||
| // - Parameters: | ||
| // - channelData[0]: Pointer to the first channel’s samples. | ||
| // - 1: Stride between consecutive elements (every sample). | ||
| // - &rms: Output variable to store the computed RMS. | ||
| // - frameCount: Number of samples to process. | ||
| vDSP_rmsqv(channelData[0], 1, &rms, frameCount) | ||
|
|
||
| // Convert the linear RMS value to decibels using the formula | ||
| // 20 * log10(rms). To avoid a log of zero (which is undefined), | ||
| // use `max(rms, Float.ulpOfOne)` to ensure a minimal positive value. | ||
| let rmsDB = 20 * log10(max(rms, Float.ulpOfOne)) | ||
|
|
||
| // Clamp the computed decibel value to a reasonable audio level range | ||
| // between -160 dB (silence) and 0 dB (maximum). This prevents extreme | ||
| // or invalid values that may occur due to noise or computation errors. | ||
| let clampedRMS = max(-160.0, min(0.0, Float(rmsDB))) | ||
|
|
||
| // Publish the clamped decibel value to the CurrentValueSubject so that | ||
| // subscribers (e.g., UI level meters or analytics systems) receive the | ||
| // updated level reading. | ||
| subject.send(clampedRMS) | ||
| } | ||
| } | ||
47 changes: 47 additions & 0 deletions
47
ios/RCTWebRTC/Utils/AudioDeviceModule/RTCAudioDeviceModuleControlling.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| // | ||
| // Copyright © 2025 Stream.io Inc. All rights reserved. | ||
| // | ||
|
|
||
| import Combine | ||
| import WebRTC | ||
|
|
||
| /// Abstraction over `RTCAudioDeviceModule` so tests can provide fakes while | ||
| /// production code continues to rely on the WebRTC-backed implementation. | ||
| protocol RTCAudioDeviceModuleControlling: AnyObject { | ||
| var observer: RTCAudioDeviceModuleDelegate? { get set } | ||
| var isPlaying: Bool { get } | ||
| var isRecording: Bool { get } | ||
| var isPlayoutInitialized: Bool { get } | ||
| var isRecordingInitialized: Bool { get } | ||
| var isMicrophoneMuted: Bool { get } | ||
| var isStereoPlayoutEnabled: Bool { get } | ||
| var isVoiceProcessingBypassed: Bool { get set } | ||
| var isVoiceProcessingEnabled: Bool { get } | ||
| var isVoiceProcessingAGCEnabled: Bool { get } | ||
| var prefersStereoPlayout: Bool { get set } | ||
|
|
||
| func reset() -> Int | ||
| func initAndStartPlayout() -> Int | ||
| func startPlayout() -> Int | ||
| func stopPlayout() -> Int | ||
| func initAndStartRecording() -> Int | ||
| func setMicrophoneMuted(_ isMuted: Bool) -> Int | ||
| func startRecording() -> Int | ||
| func stopRecording() -> Int | ||
| func refreshStereoPlayoutState() | ||
| func setMuteMode(_ mode: RTCAudioEngineMuteMode) -> Int | ||
| func setRecordingAlwaysPreparedMode(_ alwaysPreparedRecording: Bool) -> Int | ||
| } | ||
|
|
||
| extension RTCAudioDeviceModule: RTCAudioDeviceModuleControlling { | ||
| /// Convenience wrapper that mirrors the old `initPlayout` and | ||
| /// `startPlayout` sequence so the caller can request playout in one call. | ||
| func initAndStartPlayout() -> Int { | ||
| let result = initPlayout() | ||
| if result == 0 { | ||
| return startPlayout() | ||
| } else { | ||
| return result | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
🧩 Analysis chain
🏁 Script executed:
Repository: GetStream/react-native-webrtc
Length of output: 146
🏁 Script executed:
Repository: GetStream/react-native-webrtc
Length of output: 5446
Use the provided bus parameter when removing the tap.
Line 67 hardcodes
onBus: 0instead of using thebusparameter, whileinstallInputTapcorrectly uses it (line 52). This causes taps installed on non-zero buses to persist afteruninstallis called, leaking taps and keeping callbacks running.🐛 Proposed fix
func uninstall(on bus: Int = 0) { if let mixer = inputTap, mixer.engine != nil { - mixer.removeTap(onBus: 0) + mixer.removeTap(onBus: bus) }🤖 Prompt for AI Agents