Files
letro-ios/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift

250 lines
8.8 KiB
Swift

//
// Copyright 2023, 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import DSWaveformImage
import Foundation
import MatrixRustSDK
class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
let audioRecorder: AudioRecorderProtocol
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"
var isRecording: Bool {
audioRecorder.isRecording
}
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,
voiceMessageCache: VoiceMessageCacheProtocol = VoiceMessageCache()) {
self.audioRecorder = audioRecorder
self.mediaPlayerProvider = mediaPlayerProvider
self.voiceMessageCache = voiceMessageCache
addObservers()
}
deinit {
removeObservers()
}
// MARK: - Recording
func startRecording() async {
await stopPlayback()
previewAudioPlayer?.reset()
recordingCancelled = false
await audioRecorder.record(audioFileURL: voiceMessageCache.urlForRecording)
}
func stopRecording() async {
recordingCancelled = false
await audioRecorder.stopRecording()
}
func cancelRecording() async {
MXLog.info("Cancel recording.")
recordingCancelled = true
await audioRecorder.stopRecording()
await audioRecorder.deleteRecording()
previewAudioPlayerState = nil
previewAudioPlayer?.reset()
}
func deleteRecording() async {
MXLog.info("Delete recording.")
await stopPlayback()
await audioRecorder.deleteRecording()
previewAudioPlayer?.reset()
previewAudioPlayerState = nil
}
// MARK: - Preview
func startPlayback() async -> Result<Void, VoiceMessageRecorderError> {
guard let previewAudioPlayerState, let url = audioRecorder.audioFileURL else {
return .failure(.previewNotAvailable)
}
guard let audioPlayer = previewAudioPlayer else {
return .failure(.previewNotAvailable)
}
if await !previewAudioPlayerState.isAttached {
await previewAudioPlayerState.attachAudioPlayer(audioPlayer)
}
if audioPlayer.playbackURL == url {
audioPlayer.play()
return .success(())
}
audioPlayer.load(sourceURL: url, playbackURL: url, autoplay: true)
return .success(())
}
func pausePlayback() {
previewAudioPlayer?.pause()
}
func stopPlayback() async {
guard let previewAudioPlayerState else {
return
}
await previewAudioPlayerState.detachAudioPlayer()
previewAudioPlayer?.stop()
}
func seekPlayback(to progress: Double) async {
await previewAudioPlayerState?.updateState(progress: progress)
}
func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> {
guard let url = audioRecorder.audioFileURL else {
return .failure(.missingRecordingFile)
}
// build the waveform
var waveformData: [UInt16] = []
let analyzer = WaveformAnalyzer()
do {
let samples = try await analyzer.samples(fromAudioAt: url, count: 100)
// linearly normalized to [0, 1] (1 -> -50 dB)
waveformData = samples.map { UInt16(max(0, (1 - $0) * 1024)) }
} catch {
MXLog.error("Waveform analysis failed. \(error)")
return .failure(.waveformAnalysisError)
}
return .success(waveformData)
}
func sendVoiceMessage(timelineController: TimelineControllerProtocol,
audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError> {
guard let url = audioRecorder.audioFileURL else {
return .failure(VoiceMessageRecorderError.missingRecordingFile)
}
// convert the file
let sourceFilename = url.deletingPathExtension().lastPathComponent
let oggFile = URL.temporaryDirectory.appendingPathComponent(sourceFilename).appendingPathExtension("ogg")
defer {
// delete the temporary file
try? FileManager.default.removeItem(at: oggFile)
}
do {
try audioConverter.convertToOpusOgg(sourceURL: url, destinationURL: oggFile)
} catch {
return .failure(.failedSendingVoiceMessage)
}
// send it
let size: UInt64
do {
size = try UInt64(FileManager.default.sizeForItem(at: oggFile))
} catch {
MXLog.error("Failed to get the recording file size. \(error)")
return .failure(.failedSendingVoiceMessage)
}
let audioInfo = AudioInfo(duration: recordingDuration, size: size, mimetype: "audio/ogg")
guard case .success(let waveform) = await buildRecordingWaveform() else {
return .failure(.failedSendingVoiceMessage)
}
let result = await timelineController.sendVoiceMessage(url: oggFile,
audioInfo: audioInfo,
waveform: waveform) { _ in }
if case .failure(let error) = result {
MXLog.error("Failed to send the voice message. \(error)")
return .failure(.failedSendingVoiceMessage)
}
return .success(())
}
// 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> {
MXLog.info("finalize audio recording")
guard audioRecorder.audioFileURL != nil, audioRecorder.currentTime > 0 else {
return .failure(.previewNotAvailable)
}
// Build the preview audio player state
previewAudioPlayerState = await AudioPlayerState(id: .recorderPreview, title: L10n.commonVoiceMessage, duration: recordingDuration, waveform: EstimatedWaveform(data: []))
// Build the preview audio player
let audioPlayer = await mediaPlayerProvider.player
previewAudioPlayer = audioPlayer
return .success(())
}
}