Skip to content

Commit 2f867a8

Browse files
authored
[Fix]Local audio waveform not always working (#912)
1 parent c6efb2d commit 2f867a8

File tree

56 files changed

+4586
-597
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+4586
-597
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44

55
# Upcoming
66

7-
### 🔄 Changed
7+
### 🐞 Fixed
8+
- An issue that was causing the local participant's audio waveform visualization to stop working. [#912](https://github.com/GetStream/stream-video-swift/pull/912)
89

910
# [1.30.0](https://github.com/GetStream/stream-video-swift/releases/tag/1.30.0)
1011
_August 08, 2025_

Sources/StreamVideo/StreamVideo.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,6 @@ public class StreamVideo: ObservableObject, @unchecked Sendable {
188188
middlewares: [defaultParams]
189189
)
190190
StreamVideoProviderKey.currentValue = self
191-
// This is used from the `StreamCallAudioRecorder` to observe active
192-
// calls and activate/deactivate the AudioSession.
193-
StreamActiveCallProviderKey.currentValue = self
194191

195192
// Update the streamVideo instance on the noiseCancellationFilter
196193
// to allow it to observe the activeCall state.

Sources/StreamVideo/Utils/AudioSession/AudioRecorder/AVAudioRecorderBuilder.swift

Lines changed: 0 additions & 68 deletions
This file was deleted.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import AVFoundation
6+
import Foundation
7+
8+
/// Convenience extensions for `AVAudioRecorder` to simplify audio recording
9+
/// setup.
10+
extension AVAudioRecorder {
11+
12+
/// Creates a new audio recorder with default settings optimized for voice
13+
/// recording.
14+
///
15+
/// This convenience method simplifies recorder creation by:
16+
/// - Automatically selecting the app's cache directory for storage
17+
/// - Providing sensible default recording settings for voice
18+
/// - Handling file URL construction
19+
///
20+
/// ## Default Settings
21+
///
22+
/// The default settings are optimized for voice recording:
23+
/// - **Format**: Linear PCM (uncompressed, highest quality)
24+
/// - **Sample Rate**: 12 kHz (suitable for voice)
25+
/// - **Channels**: Mono (single channel)
26+
/// - **Quality**: High
27+
///
28+
/// ## File Storage
29+
///
30+
/// Audio files are stored in the app's cache directory, which:
31+
/// - Doesn't require user permission
32+
/// - Is automatically managed by iOS
33+
/// - Can be cleared when device storage is low
34+
///
35+
/// ## Usage Example
36+
///
37+
/// ```swift
38+
/// // Create recorder with default settings
39+
/// let recorder = try AVAudioRecorder.build()
40+
///
41+
/// // Create recorder with custom filename
42+
/// let customRecorder = try AVAudioRecorder.build(
43+
/// filename: "interview.wav"
44+
/// )
45+
///
46+
/// // Create recorder with custom settings
47+
/// let highQualityRecorder = try AVAudioRecorder.build(
48+
/// filename: "music.wav",
49+
/// settings: [
50+
/// AVFormatIDKey: Int(kAudioFormatLinearPCM),
51+
/// AVSampleRateKey: 44100,
52+
/// AVNumberOfChannelsKey: 2,
53+
/// AVEncoderAudioQualityKey: AVAudioQuality.max.rawValue
54+
/// ]
55+
/// )
56+
/// ```
57+
///
58+
/// - Parameters:
59+
/// - filename: The name of the audio file to create. Defaults to
60+
/// "recording.wav". The file will be stored in the app's cache
61+
/// directory.
62+
/// - settings: Dictionary of audio recording settings. Defaults to
63+
/// settings optimized for voice recording. See `AVAudioRecorder`
64+
/// documentation for available keys.
65+
///
66+
/// - Returns: A configured `AVAudioRecorder` instance ready for
67+
/// recording.
68+
///
69+
/// - Throws: An error if the recorder cannot be initialized, typically
70+
/// due to:
71+
/// - Invalid recording settings
72+
/// - File system errors
73+
/// - Audio session configuration issues
74+
///
75+
/// - Note: Remember to configure the audio session and request microphone
76+
/// permission before attempting to record.
77+
///
78+
/// - Important: Linear PCM format is used by default to support multiple
79+
/// simultaneous `AVAudioRecorder` instances. Compressed formats may
80+
/// limit you to a single recorder at a time.
81+
static func build(
82+
filename: String = "recording.wav",
83+
settings: [String: Any] = [
84+
AVFormatIDKey: Int(kAudioFormatLinearPCM),
85+
AVSampleRateKey: 12000,
86+
AVNumberOfChannelsKey: 1,
87+
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
88+
]
89+
) throws -> AVAudioRecorder {
90+
// Get the cache directory for temporary audio storage
91+
guard
92+
let documentPath = FileManager.default.urls(
93+
for: .cachesDirectory,
94+
in: .userDomainMask
95+
).first
96+
else {
97+
throw ClientError("No cache directory available.")
98+
}
99+
100+
// Construct the full file URL
101+
let fileURL = documentPath.appendingPathComponent(filename)
102+
103+
// Create and return the configured recorder
104+
return try .init(url: fileURL, settings: settings)
105+
}
106+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import AVFoundation
6+
import Combine
7+
import Foundation
8+
9+
extension StreamCallAudioRecorder.Namespace {
10+
/// Middleware that manages the `AVAudioRecorder` instance for audio
11+
/// recording.
12+
///
13+
/// This middleware handles:
14+
/// - Creating and configuring the audio recorder
15+
/// - Starting and stopping recording based on state changes
16+
/// - Publishing audio meter levels at the display refresh rate
17+
/// - Managing recording permissions
18+
///
19+
/// ## Thread Safety
20+
///
21+
/// Recording operations are performed on a serial operation queue to
22+
/// ensure thread safety when accessing the recorder instance.
23+
final class AVAudioRecorderMiddleware: Middleware<StreamCallAudioRecorder.Namespace>, @unchecked Sendable {
24+
25+
/// The audio store for managing permissions and session state.
26+
@Injected(\.audioStore) private var audioStore
27+
28+
/// Builder for creating and caching the audio recorder instance.
29+
private var audioRecorder: AVAudioRecorder?
30+
31+
/// Serial queue for recorder operations to ensure thread safety.
32+
private let processingQueue = OperationQueue(maxConcurrentOperationCount: 1)
33+
34+
/// Subscription for publishing meter updates at refresh rate.
35+
private var updateMetersCancellable: AnyCancellable?
36+
37+
init(audioRecorder: AVAudioRecorder? = nil) {
38+
self.audioRecorder = audioRecorder
39+
}
40+
41+
// MARK: - Middleware
42+
43+
/// Processes actions to manage audio recording state.
44+
///
45+
/// Responds to:
46+
/// - `.setIsRecording`: Starts or stops recording
47+
/// - `.setIsInterrupted`: Pauses recording during interruptions
48+
/// - `.setShouldRecord`: Initiates recording when needed
49+
///
50+
/// - Parameters:
51+
/// - state: The current store state.
52+
/// - action: The action being processed.
53+
/// - file: Source file of the action dispatch.
54+
/// - function: Function name of the action dispatch.
55+
/// - line: Line number of the action dispatch.
56+
override func apply(
57+
state: State,
58+
action: Action,
59+
file: StaticString,
60+
function: StaticString,
61+
line: UInt
62+
) {
63+
switch action {
64+
case let .setIsRecording(value):
65+
if value, state.shouldRecord {
66+
startRecording()
67+
} else {
68+
stopRecording()
69+
}
70+
case let .setIsInterrupted(value):
71+
if value {
72+
stopRecording()
73+
} else if !value, state.shouldRecord, !state.isRecording {
74+
startRecording()
75+
} else {
76+
break
77+
}
78+
79+
case let .setShouldRecord(value):
80+
if value, !state.isRecording {
81+
startRecording()
82+
} else if !value, state.isRecording {
83+
stopRecording()
84+
} else {
85+
break
86+
}
87+
88+
case .setMeter:
89+
break
90+
}
91+
}
92+
93+
// MARK: - Private Helpers
94+
95+
/// Starts audio recording asynchronously.
96+
///
97+
/// This method:
98+
/// 1. Builds the audio recorder if needed
99+
/// 2. Requests recording permission
100+
/// 3. Enables metering and starts recording
101+
/// 4. Sets up a timer to publish meter updates
102+
private func startRecording() {
103+
processingQueue.addTaskOperation { [weak self] in
104+
guard
105+
let self,
106+
updateMetersCancellable == nil
107+
else {
108+
return
109+
}
110+
111+
if audioRecorder == nil {
112+
do {
113+
self.audioRecorder = try AVAudioRecorder.build()
114+
} catch {
115+
log.error(error, subsystems: .audioRecording)
116+
return
117+
}
118+
}
119+
120+
guard let audioRecorder else {
121+
return
122+
}
123+
124+
audioRecorder.isMeteringEnabled = true
125+
guard
126+
await audioStore.requestRecordPermission(),
127+
audioRecorder.record()
128+
else {
129+
dispatcher?.dispatch(.setIsRecording(false))
130+
audioRecorder.isMeteringEnabled = false
131+
return
132+
}
133+
134+
updateMetersCancellable = DefaultTimer
135+
.publish(every: ScreenPropertiesAdapter.currentValue.refreshRate)
136+
.map { [weak audioRecorder] _ in audioRecorder?.updateMeters() }
137+
.compactMap { [weak audioRecorder] in audioRecorder?.averagePower(forChannel: 0) }
138+
.sink { [weak self] in self?.dispatcher?.dispatch(.setMeter($0)) }
139+
}
140+
}
141+
142+
/// Stops audio recording and cleans up resources.
143+
///
144+
/// This method:
145+
/// 1. Stops the active recording
146+
/// 2. Disables metering
147+
/// 3. Cancels the meter update timer
148+
private func stopRecording() {
149+
processingQueue.addOperation { [weak self] in
150+
guard
151+
let self,
152+
updateMetersCancellable != nil,
153+
let audioRecorder
154+
else {
155+
return
156+
}
157+
158+
audioRecorder.stop()
159+
audioRecorder.isMeteringEnabled = false
160+
updateMetersCancellable?.cancel()
161+
updateMetersCancellable = nil
162+
}
163+
}
164+
}
165+
}

0 commit comments

Comments
 (0)