Allow to record a voice message (#1926)

This commit is contained in:
Nicolas Mauri
2023-10-23 17:47:36 +02:00
committed by GitHub
parent 65b7c1df20
commit 2f57fbc77d
62 changed files with 1805 additions and 342 deletions
@@ -16,36 +16,31 @@
import Foundation
enum VoiceMessageCacheError: Error {
case invalidFileExtension
}
class VoiceMessageCache: VoiceMessageCacheProtocol {
private let preferredFileExtension = "m4a"
private var temporaryFilesFolderURL: URL {
FileManager.default.temporaryDirectory.appendingPathComponent("media/voice-message")
}
func fileURL(for mediaSource: MediaSourceProxy) -> URL? {
let url = cacheURL(for: mediaSource)
return FileManager.default.fileExists(atPath: url.path()) ? url : nil
}
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool = false) throws -> URL {
guard fileURL.pathExtension == preferredFileExtension else {
throw VoiceMessageCacheError.invalidFileExtension
}
setupTemporaryFilesFolder()
let url = cacheURL(for: mediaSource)
try? FileManager.default.removeItem(at: url)
if move {
try FileManager.default.moveItem(at: fileURL, to: url)
} else {
try FileManager.default.copyItem(at: fileURL, to: url)
}
return url
}
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool = false) -> Result<URL, VoiceMessageCacheError> {
guard fileURL.pathExtension == preferredFileExtension else {
return .failure(.invalidFileExtension)
}
let url = cacheURL(for: mediaSource)
do {
try cacheFile(source: fileURL, destination: url, move: move)
} catch {
MXLog.error("Failed storing file in cache", context: error)
return .failure(.failedStoringFileInCache)
}
return .success(url)
}
func clearCache() {
if FileManager.default.fileExists(atPath: temporaryFilesFolderURL.path) {
do {
@@ -66,6 +61,16 @@ class VoiceMessageCache: VoiceMessageCacheProtocol {
}
}
private func cacheFile(source: URL, destination: URL, move: Bool) throws {
setupTemporaryFilesFolder()
try? FileManager.default.removeItem(at: destination)
if move {
try FileManager.default.moveItem(at: source, to: destination)
} else {
try FileManager.default.copyItem(at: source, to: destination)
}
}
private func cacheURL(for mediaSource: MediaSourceProxy) -> URL {
temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent).appendingPathExtension(preferredFileExtension)
}
@@ -16,9 +16,26 @@
import Foundation
enum VoiceMessageCacheError: Error {
case invalidFileExtension
case failedStoringFileInCache
}
protocol VoiceMessageCacheProtocol {
/// Returns the URL of the cached audio file for a given media source
/// - Parameter mediaSource: the media source
/// - Returns: the URL of the cached audio file or nil if the file doesn't exist
func fileURL(for mediaSource: MediaSourceProxy) -> URL?
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) throws -> URL
/// Adds a file in the cache
/// - Parameters:
/// - mediaSource: the media source
/// - fileURL: the source file
/// - move: wheter to move or copy the source file
/// - Returns: the cached URL
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) -> Result<URL, VoiceMessageCacheError>
/// Clears the cache
func clearCache()
}
@@ -80,7 +80,13 @@ class VoiceMessageMediaManager: VoiceMessageMediaManagerProtocol {
try audioConverter.convertToMPEG4AAC(sourceURL: fileHandle.url, destinationURL: convertedFileURL)
// Cache the file and return the url
return try voiceMessageCache.cache(mediaSource: source, using: convertedFileURL, move: true)
let result = voiceMessageCache.cache(mediaSource: source, using: convertedFileURL, move: true)
switch result {
case .success(let url):
return url
case .failure(let error):
throw error
}
}
}
@@ -0,0 +1,198 @@
//
// Copyright 2023 New Vector Ltd
//
// 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 DSWaveformImage
import Foundation
import MatrixRustSDK
class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
let audioRecorder: AudioRecorderProtocol
private let audioConverter: AudioConverterProtocol
private let voiceMessageCache: VoiceMessageCacheProtocol
private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let mp4accMimeType = "audio/m4a"
private let waveformSamplesCount = 100
private(set) var recordingURL: URL?
private(set) var recordingDuration: TimeInterval = 0.0
private(set) var previewAudioPlayerState: AudioPlayerState?
private(set) var previewAudioPlayer: AudioPlayerProtocol?
init(audioRecorder: AudioRecorderProtocol = AudioRecorder(),
mediaPlayerProvider: MediaPlayerProviderProtocol,
audioConverter: AudioConverterProtocol = AudioConverter(),
voiceMessageCache: VoiceMessageCacheProtocol = VoiceMessageCache()) {
self.audioRecorder = audioRecorder
self.mediaPlayerProvider = mediaPlayerProvider
self.audioConverter = audioConverter
self.voiceMessageCache = voiceMessageCache
}
// MARK: - Recording
func startRecording() async -> Result<Void, VoiceMessageRecorderError> {
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(())
}
}
func stopRecording() async -> Result<Void, VoiceMessageRecorderError> {
recordingDuration = audioRecorder.currentTime
await audioRecorder.stopRecording()
guard case .success = await finalizeRecording() else {
return .failure(.previewNotAvailable)
}
return .success(())
}
func cancelRecording() async {
await audioRecorder.stopRecording()
audioRecorder.deleteRecording()
recordingURL = nil
previewAudioPlayerState = nil
}
func deleteRecording() async {
await stopPlayback()
audioRecorder.deleteRecording()
previewAudioPlayerState = nil
recordingURL = nil
}
// MARK: - Preview
func startPlayback() async -> Result<Void, VoiceMessageRecorderError> {
guard let previewAudioPlayerState, let url = recordingURL else {
return .failure(.previewNotAvailable)
}
guard let audioPlayer = previewAudioPlayer else {
return .failure(.previewNotAvailable)
}
if audioPlayer.url == url {
audioPlayer.play()
return .success(())
}
await previewAudioPlayerState.attachAudioPlayer(audioPlayer)
let pendingMediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType)
audioPlayer.load(mediaSource: pendingMediaSource, using: 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 = recordingURL 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 .success(waveformData)
}
func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError> {
guard let url = recordingURL else {
return .failure(VoiceMessageRecorderError.missingRecordingFile)
}
// convert the file
let sourceFilename = url.deletingPathExtension().lastPathComponent
let oggFile = URL.temporaryDirectory.appendingPathComponent(sourceFilename).appendingPathExtension("ogg")
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", context: 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 roomProxy.sendVoiceMessage(url: oggFile,
audioInfo: audioInfo,
waveform: waveform,
progressSubject: nil) { _ in }
// delete the temporary file
try? FileManager.default.removeItem(at: oggFile)
if case .failure(let error) = result {
MXLog.error("Failed to send the voice message.", context: error)
return .failure(.failedSendingVoiceMessage)
}
return .success(())
}
// MARK: - Private
private func finalizeRecording() async -> Result<Void, VoiceMessageRecorderError> {
guard let url = recordingURL else {
return .failure(.previewNotAvailable)
}
// Build the preview audio player state
previewAudioPlayerState = await AudioPlayerState(id: .recorderPreview, duration: recordingDuration, waveform: EstimatedWaveform(data: []))
// Build the preview audio player
let mediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType)
guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: mediaSource), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
return .failure(.previewNotAvailable)
}
previewAudioPlayer = audioPlayer
return .success(())
}
}
@@ -0,0 +1,47 @@
//
// Copyright 2023 New Vector Ltd
//
// 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 Foundation
enum VoiceMessageRecorderError: Error {
case genericError
case missingRecordingFile
case previewNotAvailable
case audioRecorderError(AudioRecorderError)
case failedSendingVoiceMessage
}
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>
func cancelRecording() async
func startPlayback() async -> Result<Void, VoiceMessageRecorderError>
func pausePlayback()
func stopPlayback() async
func seekPlayback(to progress: Double) async
func deleteRecording() async
func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError>
func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError>
}
// sourcery: AutoMockable
extension VoiceMessageRecorderProtocol { }