mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
Voice message recording enhancements (#2042)
* Use Combine to report AudioRecorder state changes * AudioRecorder is now using AVAudioEngine instead of AVAudioRecorder * Stop recording when audio configuration changes * AudioRecorder error handling * UnitTests * Use Accelerate to calculate RMS
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import DSWaveformImage
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
@@ -24,15 +25,28 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
private let voiceMessageCache: VoiceMessageCacheProtocol
|
||||
private let mediaPlayerProvider: MediaPlayerProviderProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<VoiceMessageRecorderAction, Never> = .init()
|
||||
var actions: AnyPublisher<VoiceMessageRecorderAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private let mp4accMimeType = "audio/m4a"
|
||||
private let waveformSamplesCount = 100
|
||||
|
||||
private(set) var recordingURL: URL?
|
||||
private(set) var recordingDuration: TimeInterval = 0.0
|
||||
var recordingURL: URL? {
|
||||
audioRecorder.audioFileUrl
|
||||
}
|
||||
|
||||
var recordingDuration: TimeInterval {
|
||||
audioRecorder.currentTime
|
||||
}
|
||||
|
||||
private var recordingCancelled = false
|
||||
|
||||
private(set) var previewAudioPlayerState: AudioPlayerState?
|
||||
private(set) var previewAudioPlayer: AudioPlayerProtocol?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(audioRecorder: AudioRecorderProtocol = AudioRecorder(),
|
||||
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
||||
audioConverter: AudioConverterProtocol = AudioConverter(),
|
||||
@@ -41,35 +55,32 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
self.mediaPlayerProvider = mediaPlayerProvider
|
||||
self.audioConverter = audioConverter
|
||||
self.voiceMessageCache = voiceMessageCache
|
||||
|
||||
addObservers()
|
||||
}
|
||||
|
||||
deinit {
|
||||
removeObservers()
|
||||
}
|
||||
|
||||
// MARK: - Recording
|
||||
|
||||
func startRecording() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
func startRecording() async {
|
||||
await stopPlayback()
|
||||
recordingURL = nil
|
||||
switch await audioRecorder.record(with: .uuid(UUID())) {
|
||||
case .failure(let error):
|
||||
return .failure(.audioRecorderError(error))
|
||||
case .success:
|
||||
recordingURL = audioRecorder.url
|
||||
return .success(())
|
||||
}
|
||||
recordingCancelled = false
|
||||
|
||||
await audioRecorder.record(with: .uuid(UUID()))
|
||||
}
|
||||
|
||||
func stopRecording() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
recordingDuration = audioRecorder.currentTime
|
||||
func stopRecording() async {
|
||||
recordingCancelled = false
|
||||
await audioRecorder.stopRecording()
|
||||
guard case .success = await finalizeRecording() else {
|
||||
return .failure(.previewNotAvailable)
|
||||
}
|
||||
return .success(())
|
||||
}
|
||||
|
||||
func cancelRecording() async {
|
||||
recordingCancelled = true
|
||||
await audioRecorder.stopRecording()
|
||||
await audioRecorder.deleteRecording()
|
||||
recordingURL = nil
|
||||
previewAudioPlayerState = nil
|
||||
}
|
||||
|
||||
@@ -77,13 +88,12 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
await stopPlayback()
|
||||
await audioRecorder.deleteRecording()
|
||||
previewAudioPlayerState = nil
|
||||
recordingURL = nil
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
|
||||
func startPlayback() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
guard let previewAudioPlayerState, let url = recordingURL else {
|
||||
guard let previewAudioPlayerState, let url = audioRecorder.audioFileUrl else {
|
||||
return .failure(.previewNotAvailable)
|
||||
}
|
||||
|
||||
@@ -122,7 +132,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
}
|
||||
|
||||
func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> {
|
||||
guard let url = recordingURL else {
|
||||
guard let url = audioRecorder.audioFileUrl else {
|
||||
return .failure(.missingRecordingFile)
|
||||
}
|
||||
// build the waveform
|
||||
@@ -134,12 +144,13 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
waveformData = samples.map { UInt16(max(0, (1 - $0) * 1024)) }
|
||||
} catch {
|
||||
MXLog.error("Waveform analysis failed. \(error)")
|
||||
return .failure(.waveformAnalysisError)
|
||||
}
|
||||
return .success(waveformData)
|
||||
}
|
||||
|
||||
func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError> {
|
||||
guard let url = recordingURL else {
|
||||
guard let url = audioRecorder.audioFileUrl else {
|
||||
return .failure(VoiceMessageRecorderError.missingRecordingFile)
|
||||
}
|
||||
|
||||
@@ -182,8 +193,49 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func addObservers() {
|
||||
audioRecorder.actions
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
self.handleAudioRecorderAction(action)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func removeObservers() {
|
||||
cancellables.removeAll()
|
||||
}
|
||||
|
||||
private func handleAudioRecorderAction(_ action: AudioRecorderAction) {
|
||||
switch action {
|
||||
case .didStartRecording:
|
||||
MXLog.info("audio recorder did start recording")
|
||||
actionsSubject.send(.didStartRecording(audioRecorder: audioRecorder))
|
||||
case .didStopRecording, .didFailWithError(error: .interrupted):
|
||||
MXLog.info("audio recorder did stop recording")
|
||||
if !recordingCancelled {
|
||||
Task {
|
||||
guard case .success = await finalizeRecording() else {
|
||||
actionsSubject.send(.didFailWithError(error: VoiceMessageRecorderError.previewNotAvailable))
|
||||
return
|
||||
}
|
||||
guard let recordingURL = audioRecorder.audioFileUrl, let previewAudioPlayerState else {
|
||||
actionsSubject.send(.didFailWithError(error: VoiceMessageRecorderError.previewNotAvailable))
|
||||
return
|
||||
}
|
||||
await mediaPlayerProvider.register(audioPlayerState: previewAudioPlayerState)
|
||||
actionsSubject.send(.didStopRecording(previewState: previewAudioPlayerState, url: recordingURL))
|
||||
}
|
||||
}
|
||||
case .didFailWithError(let error):
|
||||
MXLog.info("audio recorder did failed with error: \(error)")
|
||||
actionsSubject.send(.didFailWithError(error: .audioRecorderError(error)))
|
||||
}
|
||||
}
|
||||
|
||||
private func finalizeRecording() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
guard let url = recordingURL else {
|
||||
MXLog.info("finalize audio recording")
|
||||
guard let url = audioRecorder.audioFileUrl, audioRecorder.currentTime > 0 else {
|
||||
return .failure(.previewNotAvailable)
|
||||
}
|
||||
|
||||
@@ -196,6 +248,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
return .failure(.previewNotAvailable)
|
||||
}
|
||||
previewAudioPlayer = audioPlayer
|
||||
|
||||
return .success(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum VoiceMessageRecorderError: Error {
|
||||
@@ -21,17 +22,26 @@ enum VoiceMessageRecorderError: Error {
|
||||
case missingRecordingFile
|
||||
case previewNotAvailable
|
||||
case audioRecorderError(AudioRecorderError)
|
||||
case waveformAnalysisError
|
||||
case failedSendingVoiceMessage
|
||||
}
|
||||
|
||||
enum VoiceMessageRecorderAction {
|
||||
case didStartRecording(audioRecorder: AudioRecorderProtocol)
|
||||
case didStopRecording(previewState: AudioPlayerState, url: URL)
|
||||
case didFailWithError(error: VoiceMessageRecorderError)
|
||||
}
|
||||
|
||||
protocol VoiceMessageRecorderProtocol {
|
||||
var audioRecorder: AudioRecorderProtocol { get }
|
||||
var previewAudioPlayerState: AudioPlayerState? { get }
|
||||
var recordingURL: URL? { get }
|
||||
var recordingDuration: TimeInterval { get }
|
||||
|
||||
func startRecording() async -> Result<Void, VoiceMessageRecorderError>
|
||||
func stopRecording() async -> Result<Void, VoiceMessageRecorderError>
|
||||
|
||||
var actions: AnyPublisher<VoiceMessageRecorderAction, Never> { get }
|
||||
|
||||
func startRecording() async
|
||||
func stopRecording() async
|
||||
func cancelRecording() async
|
||||
func startPlayback() async -> Result<Void, VoiceMessageRecorderError>
|
||||
func pausePlayback()
|
||||
|
||||
Reference in New Issue
Block a user