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:
Nicolas Mauri
2023-11-08 10:19:28 +01:00
committed by GitHub
parent 14e0eba914
commit e04a55eaef
13 changed files with 817 additions and 269 deletions
@@ -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()