Skip to content

Commit c81fa02

Browse files
authored
1 parent db18fa9 commit c81fa02

File tree

3 files changed

+191
-3
lines changed

3 files changed

+191
-3
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ let package = Package(
1717
),
1818
],
1919
dependencies: [
20-
.package(url: "https://github.com/livekit/client-sdk-swift.git", from: "2.0.14"),
20+
.package(url: "https://github.com/livekit/client-sdk-swift.git", from: "2.0.17"),
2121
.package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.4.3"),
2222
],
2323
targets: [

Sources/LiveKitComponents/UI/Participant/ParticipantView.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,16 @@ public struct ParticipantView: View {
3131
GeometryReader { geometry in
3232
ZStack(alignment: .topLeading) {
3333
let cameraReference = TrackReference(participant: _participant, source: .camera)
34+
let microphoneReference = TrackReference(participant: _participant, source: .microphone)
3435

35-
if cameraReference.isResolvable {
36+
if let cameraTrack = cameraReference.resolve(), !cameraTrack.isMuted {
3637
VideoTrackView(trackReference: cameraReference)
3738
} else {
38-
_ui.videoDisabledView(geometry: geometry)
39+
if let microphoneTrack = microphoneReference.resolve(), !microphoneTrack.isMuted, let audioTrack = microphoneTrack.track as? AudioTrack {
40+
BarAudioVisualizer(audioTrack: audioTrack)
41+
} else {
42+
_ui.videoDisabledView(geometry: geometry)
43+
}
3944
}
4045

4146
if _showInformation {
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/*
2+
* Copyright 2024 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import AVFoundation
18+
import LiveKit
19+
import SwiftUI
20+
21+
class AudioProcessor: ObservableObject, AudioRenderer {
22+
private weak var _track: AudioTrack?
23+
private let isCentered: Bool
24+
public let smoothingFactor: Float
25+
26+
// Normalized to 0.0-1.0 range.
27+
@Published var bands: [Float]
28+
29+
private let _processor: AudioVisualizeProcessor
30+
31+
init(track: AudioTrack?,
32+
bandCount: Int,
33+
isCentered: Bool = true,
34+
smoothingFactor: Float = 0.3)
35+
{
36+
self.isCentered = isCentered
37+
self.smoothingFactor = smoothingFactor
38+
bands = Array(repeating: 0.0, count: bandCount)
39+
40+
_processor = AudioVisualizeProcessor(bandsCount: bandCount)
41+
_track = track
42+
_track?.add(audioRenderer: self)
43+
}
44+
45+
deinit {
46+
_track?.remove(audioRenderer: self)
47+
}
48+
49+
func render(pcmBuffer: AVAudioPCMBuffer) {
50+
let newBands = _processor.process(pcmBuffer: pcmBuffer)
51+
guard var newBands else { return }
52+
53+
// If centering is enabled, rearrange the normalized bands
54+
if isCentered {
55+
newBands.sort(by: >)
56+
newBands = centerBands(newBands)
57+
}
58+
59+
DispatchQueue.main.async { [weak self] in
60+
guard let self else { return }
61+
62+
self.bands = zip(self.bands, newBands).map { old, new in
63+
self._smoothTransition(from: old, to: new, factor: self.smoothingFactor)
64+
}
65+
}
66+
}
67+
68+
// MARK: - Private
69+
70+
/// Centers the sorted bands by placing higher values in the middle.
71+
@inline(__always) private func centerBands(_ sortedBands: [Float]) -> [Float] {
72+
var centeredBands = [Float](repeating: 0, count: sortedBands.count)
73+
var leftIndex = sortedBands.count / 2
74+
var rightIndex = leftIndex
75+
76+
for (index, value) in sortedBands.enumerated() {
77+
if index % 2 == 0 {
78+
// Place value to the right
79+
centeredBands[rightIndex] = value
80+
rightIndex += 1
81+
} else {
82+
// Place value to the left
83+
leftIndex -= 1
84+
centeredBands[leftIndex] = value
85+
}
86+
}
87+
88+
return centeredBands
89+
}
90+
91+
/// Applies an easing function to smooth the transition.
92+
@inline(__always) private func _smoothTransition(from oldValue: Float, to newValue: Float, factor: Float) -> Float {
93+
// Calculate the delta change between the old and new value
94+
let delta = newValue - oldValue
95+
// Apply an ease-in-out cubic easing curve
96+
let easedFactor = _easeInOutCubic(t: factor)
97+
// Calculate and return the smoothed value
98+
return oldValue + delta * easedFactor
99+
}
100+
101+
/// Easing function: ease-in-out cubic
102+
@inline(__always) private func _easeInOutCubic(t: Float) -> Float {
103+
t < 0.5 ? 4 * t * t * t : 1 - pow(-2 * t + 2, 3) / 2
104+
}
105+
}
106+
107+
/// A SwiftUI view that visualizes audio levels as a series of vertical bars,
108+
/// responding to real-time audio data processed from an audio track.
109+
///
110+
/// `BarAudioVisualizer` displays bars whose heights and opacities dynamically
111+
/// reflect the magnitude of audio frequencies in real time, creating an
112+
/// interactive, visual representation of the audio track's spectrum. This
113+
/// visualizer can be customized in terms of bar count, color, corner radius,
114+
/// spacing, and whether the bars are centered based on frequency magnitude.
115+
///
116+
/// Usage:
117+
/// ```
118+
/// let audioTrack: AudioTrack = ...
119+
/// BarAudioVisualizer(audioTrack: audioTrack)
120+
/// ```
121+
///
122+
/// - Parameters:
123+
/// - audioTrack: The `AudioTrack` providing audio data to be visualized.
124+
/// - barColor: The color used to fill each bar, defaulting to white.
125+
/// - barCount: The number of bars displayed, defaulting to 7.
126+
/// - barCornerRadius: The corner radius applied to each bar, giving a
127+
/// rounded appearance. Defaults to 100.
128+
/// - barSpacingFactor: Determines the spacing between bars as a factor
129+
/// of view width. Defaults to 0.015.
130+
/// - isCentered: A Boolean indicating whether higher-decibel bars
131+
/// should be centered. Defaults to `true`.
132+
///
133+
/// Example:
134+
/// ```
135+
/// BarAudioVisualizer(audioTrack: audioTrack, barColor: .blue, barCount: 10)
136+
/// ```
137+
struct BarAudioVisualizer: View {
138+
public let barCount: Int
139+
public let barColor: Color
140+
public let barCornerRadius: CGFloat
141+
public let barSpacingFactor: CGFloat
142+
public let isCentered: Bool
143+
144+
public let audioTrack: AudioTrack
145+
146+
@StateObject private var audioProcessor: AudioProcessor
147+
148+
init(audioTrack: AudioTrack,
149+
barColor: Color = .white,
150+
barCount: Int = 7,
151+
barCornerRadius: CGFloat = 100,
152+
barSpacingFactor: CGFloat = 0.015,
153+
isCentered: Bool = true)
154+
{
155+
self.audioTrack = audioTrack
156+
self.barColor = barColor
157+
self.barCount = barCount
158+
self.barCornerRadius = barCornerRadius
159+
self.barSpacingFactor = barSpacingFactor
160+
self.isCentered = isCentered
161+
162+
_audioProcessor = StateObject(wrappedValue: AudioProcessor(track: audioTrack,
163+
bandCount: barCount,
164+
isCentered: isCentered))
165+
}
166+
167+
var body: some View {
168+
GeometryReader { geometry in
169+
HStack(alignment: .center, spacing: geometry.size.width * barSpacingFactor) {
170+
ForEach(0 ..< audioProcessor.bands.count, id: \.self) { index in
171+
VStack {
172+
Spacer()
173+
RoundedRectangle(cornerRadius: barCornerRadius)
174+
.fill(barColor.opacity(Double(audioProcessor.bands[index])))
175+
.frame(height: CGFloat(audioProcessor.bands[index]) * geometry.size.height)
176+
Spacer()
177+
}
178+
}
179+
}
180+
}
181+
.padding()
182+
}
183+
}

0 commit comments

Comments
 (0)