diff --git a/Package.swift b/Package.swift index dbcef32..f731505 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/livekit/client-sdk-swift.git", from: "2.0.14"), + .package(url: "https://github.com/livekit/client-sdk-swift.git", from: "2.0.17"), .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.4.3"), ], targets: [ diff --git a/Sources/LiveKitComponents/UI/Participant/ParticipantView.swift b/Sources/LiveKitComponents/UI/Participant/ParticipantView.swift index bc8b7c7..bde8be3 100644 --- a/Sources/LiveKitComponents/UI/Participant/ParticipantView.swift +++ b/Sources/LiveKitComponents/UI/Participant/ParticipantView.swift @@ -31,11 +31,16 @@ public struct ParticipantView: View { GeometryReader { geometry in ZStack(alignment: .topLeading) { let cameraReference = TrackReference(participant: _participant, source: .camera) + let microphoneReference = TrackReference(participant: _participant, source: .microphone) - if cameraReference.isResolvable { + if let cameraTrack = cameraReference.resolve(), !cameraTrack.isMuted { VideoTrackView(trackReference: cameraReference) } else { - _ui.videoDisabledView(geometry: geometry) + if let microphoneTrack = microphoneReference.resolve(), !microphoneTrack.isMuted, let audioTrack = microphoneTrack.track as? AudioTrack { + BarAudioVisualizer(audioTrack: audioTrack) + } else { + _ui.videoDisabledView(geometry: geometry) + } } if _showInformation { diff --git a/Sources/LiveKitComponents/UI/Visualizer/Visualizer.swift b/Sources/LiveKitComponents/UI/Visualizer/Visualizer.swift new file mode 100644 index 0000000..5c55b7a --- /dev/null +++ b/Sources/LiveKitComponents/UI/Visualizer/Visualizer.swift @@ -0,0 +1,183 @@ +/* + * Copyright 2024 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import AVFoundation +import LiveKit +import SwiftUI + +class AudioProcessor: ObservableObject, AudioRenderer { + private weak var _track: AudioTrack? + private let isCentered: Bool + public let smoothingFactor: Float + + // Normalized to 0.0-1.0 range. + @Published var bands: [Float] + + private let _processor: AudioVisualizeProcessor + + init(track: AudioTrack?, + bandCount: Int, + isCentered: Bool = true, + smoothingFactor: Float = 0.3) + { + self.isCentered = isCentered + self.smoothingFactor = smoothingFactor + bands = Array(repeating: 0.0, count: bandCount) + + _processor = AudioVisualizeProcessor(bandsCount: bandCount) + _track = track + _track?.add(audioRenderer: self) + } + + deinit { + _track?.remove(audioRenderer: self) + } + + func render(pcmBuffer: AVAudioPCMBuffer) { + let newBands = _processor.process(pcmBuffer: pcmBuffer) + guard var newBands else { return } + + // If centering is enabled, rearrange the normalized bands + if isCentered { + newBands.sort(by: >) + newBands = centerBands(newBands) + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + self.bands = zip(self.bands, newBands).map { old, new in + self._smoothTransition(from: old, to: new, factor: self.smoothingFactor) + } + } + } + + // MARK: - Private + + /// Centers the sorted bands by placing higher values in the middle. + @inline(__always) private func centerBands(_ sortedBands: [Float]) -> [Float] { + var centeredBands = [Float](repeating: 0, count: sortedBands.count) + var leftIndex = sortedBands.count / 2 + var rightIndex = leftIndex + + for (index, value) in sortedBands.enumerated() { + if index % 2 == 0 { + // Place value to the right + centeredBands[rightIndex] = value + rightIndex += 1 + } else { + // Place value to the left + leftIndex -= 1 + centeredBands[leftIndex] = value + } + } + + return centeredBands + } + + /// Applies an easing function to smooth the transition. + @inline(__always) private func _smoothTransition(from oldValue: Float, to newValue: Float, factor: Float) -> Float { + // Calculate the delta change between the old and new value + let delta = newValue - oldValue + // Apply an ease-in-out cubic easing curve + let easedFactor = _easeInOutCubic(t: factor) + // Calculate and return the smoothed value + return oldValue + delta * easedFactor + } + + /// Easing function: ease-in-out cubic + @inline(__always) private func _easeInOutCubic(t: Float) -> Float { + t < 0.5 ? 4 * t * t * t : 1 - pow(-2 * t + 2, 3) / 2 + } +} + +/// A SwiftUI view that visualizes audio levels as a series of vertical bars, +/// responding to real-time audio data processed from an audio track. +/// +/// `BarAudioVisualizer` displays bars whose heights and opacities dynamically +/// reflect the magnitude of audio frequencies in real time, creating an +/// interactive, visual representation of the audio track's spectrum. This +/// visualizer can be customized in terms of bar count, color, corner radius, +/// spacing, and whether the bars are centered based on frequency magnitude. +/// +/// Usage: +/// ``` +/// let audioTrack: AudioTrack = ... +/// BarAudioVisualizer(audioTrack: audioTrack) +/// ``` +/// +/// - Parameters: +/// - audioTrack: The `AudioTrack` providing audio data to be visualized. +/// - barColor: The color used to fill each bar, defaulting to white. +/// - barCount: The number of bars displayed, defaulting to 7. +/// - barCornerRadius: The corner radius applied to each bar, giving a +/// rounded appearance. Defaults to 100. +/// - barSpacingFactor: Determines the spacing between bars as a factor +/// of view width. Defaults to 0.015. +/// - isCentered: A Boolean indicating whether higher-decibel bars +/// should be centered. Defaults to `true`. +/// +/// Example: +/// ``` +/// BarAudioVisualizer(audioTrack: audioTrack, barColor: .blue, barCount: 10) +/// ``` +struct BarAudioVisualizer: View { + public let barCount: Int + public let barColor: Color + public let barCornerRadius: CGFloat + public let barSpacingFactor: CGFloat + public let isCentered: Bool + + public let audioTrack: AudioTrack + + @StateObject private var audioProcessor: AudioProcessor + + init(audioTrack: AudioTrack, + barColor: Color = .white, + barCount: Int = 7, + barCornerRadius: CGFloat = 100, + barSpacingFactor: CGFloat = 0.015, + isCentered: Bool = true) + { + self.audioTrack = audioTrack + self.barColor = barColor + self.barCount = barCount + self.barCornerRadius = barCornerRadius + self.barSpacingFactor = barSpacingFactor + self.isCentered = isCentered + + _audioProcessor = StateObject(wrappedValue: AudioProcessor(track: audioTrack, + bandCount: barCount, + isCentered: isCentered)) + } + + var body: some View { + GeometryReader { geometry in + HStack(alignment: .center, spacing: geometry.size.width * barSpacingFactor) { + ForEach(0 ..< audioProcessor.bands.count, id: \.self) { index in + VStack { + Spacer() + RoundedRectangle(cornerRadius: barCornerRadius) + .fill(barColor.opacity(Double(audioProcessor.bands[index]))) + .frame(height: CGFloat(audioProcessor.bands[index]) * geometry.size.height) + Spacer() + } + } + } + } + .padding() + } +}