mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
New voice message recording mode (#2051)
* Lock voice message recording * Use the VoiceMessageCache to store the recording file * Rework on some composer toolbar buttons * Update accessibility labels for voice message recording button * PreviewTests
This commit is contained in:
@@ -105,6 +105,11 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
|
||||
addObservers()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
stop()
|
||||
unloadContent()
|
||||
}
|
||||
|
||||
func play() {
|
||||
isStopped = false
|
||||
setupAudioSession()
|
||||
|
||||
@@ -40,10 +40,11 @@ class AudioRecorder: AudioRecorderProtocol {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private let maximumRecordingTime: TimeInterval = 1800 // 30 minutes
|
||||
private let silenceThreshold: Float = -50.0
|
||||
private var meterLevel: Float = 0
|
||||
|
||||
private(set) var audioFileUrl: URL?
|
||||
private(set) var audioFileURL: URL?
|
||||
var currentTime: TimeInterval = .zero
|
||||
var isRecording: Bool {
|
||||
audioEngine?.isRunning ?? false
|
||||
@@ -56,13 +57,21 @@ class AudioRecorder: AudioRecorderProtocol {
|
||||
self.audioSession = audioSession
|
||||
}
|
||||
|
||||
func record(with recordID: AudioRecordingIdentifier) async {
|
||||
deinit {
|
||||
if isRecording {
|
||||
// Cleanup
|
||||
cleanupAudioEngine()
|
||||
deleteRecordingFile()
|
||||
}
|
||||
}
|
||||
|
||||
func record(audioFileURL: URL) async {
|
||||
stopped = false
|
||||
guard await requestRecordPermission() else {
|
||||
setInternalState(.error(.recordPermissionNotGranted))
|
||||
return
|
||||
}
|
||||
let result = await startRecording(with: recordID)
|
||||
let result = await startRecording(audioFileURL: audioFileURL)
|
||||
switch result {
|
||||
case .success:
|
||||
setInternalState(.recording)
|
||||
@@ -124,25 +133,25 @@ class AudioRecorder: AudioRecorderProtocol {
|
||||
removeObservers()
|
||||
}
|
||||
|
||||
private func startRecording(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
|
||||
private func startRecording(audioFileURL: URL) async -> Result<Void, AudioRecorderError> {
|
||||
await withCheckedContinuation { continuation in
|
||||
startRecording(with: recordID) { result in
|
||||
startRecording(audioFileURL: audioFileURL) { result in
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createAudioFile(with recordID: AudioRecordingIdentifier, sampleRate: Int) throws -> AVAudioFile {
|
||||
private func createAudioFile(at recordingURL: URL, sampleRate: Int) throws -> AVAudioFile {
|
||||
let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||
AVSampleRateKey: sampleRate,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
|
||||
MXLog.info("creating audio file with format: \(settings)")
|
||||
let outputURL = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a")
|
||||
return try AVAudioFile(forWriting: outputURL, settings: settings)
|
||||
try? FileManager.default.removeItem(at: recordingURL)
|
||||
return try AVAudioFile(forWriting: recordingURL, settings: settings)
|
||||
}
|
||||
|
||||
private func startRecording(with recordID: AudioRecordingIdentifier, completion: @escaping (Result<Void, AudioRecorderError>) -> Void) {
|
||||
private func startRecording(audioFileURL: URL, completion: @escaping (Result<Void, AudioRecorderError>) -> Void) {
|
||||
dispatchQueue.async { [weak self] in
|
||||
guard let self, !self.stopped else {
|
||||
completion(.failure(.recordingCancelled))
|
||||
@@ -170,9 +179,9 @@ class AudioRecorder: AudioRecorderProtocol {
|
||||
currentTime = 0
|
||||
let audioFile: AVAudioFile
|
||||
do {
|
||||
audioFile = try createAudioFile(with: recordID, sampleRate: Int(sampleRate))
|
||||
audioFile = try createAudioFile(at: audioFileURL, sampleRate: Int(sampleRate))
|
||||
self.audioFile = audioFile
|
||||
audioFileUrl = audioFile.url
|
||||
self.audioFileURL = audioFile.url
|
||||
} catch {
|
||||
MXLog.error("failed to create an audio file. \(error)")
|
||||
completion(.failure(.audioFileCreationFailure))
|
||||
@@ -208,6 +217,7 @@ class AudioRecorder: AudioRecorderProtocol {
|
||||
}
|
||||
|
||||
private func cleanupAudioEngine() {
|
||||
MXLog.info("cleaning up the audio engine")
|
||||
if let audioEngine {
|
||||
audioEngine.stop()
|
||||
if let mixer {
|
||||
@@ -226,14 +236,22 @@ class AudioRecorder: AudioRecorderProtocol {
|
||||
completion()
|
||||
}
|
||||
guard let self else { return }
|
||||
if let audioFileUrl {
|
||||
try? FileManager.default.removeItem(at: audioFileUrl)
|
||||
}
|
||||
audioFileUrl = nil
|
||||
deleteRecordingFile()
|
||||
audioFileURL = nil
|
||||
currentTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteRecordingFile() {
|
||||
guard let audioFileURL else { return }
|
||||
do {
|
||||
try FileManager.default.removeItem(at: audioFileURL)
|
||||
MXLog.info("recording file deleted.")
|
||||
} catch {
|
||||
MXLog.error("failed to delete recording file. \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Audio Processing
|
||||
|
||||
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
|
||||
@@ -246,6 +264,12 @@ class AudioRecorder: AudioRecorderProtocol {
|
||||
|
||||
// Update the recording duration only if we succeed to write the buffer
|
||||
currentTime += Double(buffer.frameLength) / buffer.format.sampleRate
|
||||
|
||||
// Limit the recording time
|
||||
if currentTime >= maximumRecordingTime {
|
||||
MXLog.info("Maximum recording time reach (\(maximumRecordingTime))")
|
||||
Task { await stopRecording() }
|
||||
}
|
||||
} catch {
|
||||
MXLog.error("failed to write sample. \(error)")
|
||||
}
|
||||
@@ -331,7 +355,6 @@ class AudioRecorder: AudioRecorderProtocol {
|
||||
private func setInternalState(_ state: InternalAudioRecorderState) {
|
||||
dispatchQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
guard internalState != state else { return }
|
||||
MXLog.debug("internal state: \(internalState) -> \(state)")
|
||||
internalState = state
|
||||
|
||||
|
||||
@@ -17,19 +17,6 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum AudioRecordingIdentifier {
|
||||
case uuid(UUID)
|
||||
}
|
||||
|
||||
extension AudioRecordingIdentifier {
|
||||
var identifier: String {
|
||||
switch self {
|
||||
case .uuid(let uuid):
|
||||
return uuid.uuidString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AudioRecorderError: Error, Equatable {
|
||||
case audioEngineFailure
|
||||
case audioFileCreationFailure
|
||||
@@ -49,9 +36,9 @@ protocol AudioRecorderProtocol: AnyObject {
|
||||
var actions: AnyPublisher<AudioRecorderAction, Never> { get }
|
||||
var currentTime: TimeInterval { get }
|
||||
var isRecording: Bool { get }
|
||||
var audioFileUrl: URL? { get }
|
||||
var audioFileURL: URL? { get }
|
||||
|
||||
func record(with recordID: AudioRecordingIdentifier) async
|
||||
func record(audioFileURL: URL) async
|
||||
func stopRecording() async
|
||||
func deleteRecording() async
|
||||
func averagePower() -> Float
|
||||
|
||||
@@ -41,6 +41,7 @@ class AudioRecorderState: ObservableObject, Identifiable {
|
||||
self.audioRecorder = audioRecorder
|
||||
subscribeToAudioRecorder(audioRecorder)
|
||||
if audioRecorder.isRecording {
|
||||
recordingState = .recording
|
||||
startPublishUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ protocol MediaPlayerProtocol: AnyObject {
|
||||
var state: MediaPlayerState { get }
|
||||
|
||||
func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool)
|
||||
func reset()
|
||||
func play()
|
||||
func pause()
|
||||
func stop()
|
||||
|
||||
@@ -22,6 +22,12 @@ class VoiceMessageCache: VoiceMessageCacheProtocol {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent("media/voice-message")
|
||||
}
|
||||
|
||||
var urlForRecording: URL {
|
||||
// Make sure the directory exist
|
||||
setupTemporaryFilesFolder()
|
||||
return temporaryFilesFolderURL.appendingPathComponent("voice-message-recording").appendingPathExtension(preferredFileExtension)
|
||||
}
|
||||
|
||||
func fileURL(for mediaSource: MediaSourceProxy) -> URL? {
|
||||
let url = cacheURL(for: mediaSource)
|
||||
return FileManager.default.fileExists(atPath: url.path()) ? url : nil
|
||||
@@ -69,6 +75,7 @@ class VoiceMessageCache: VoiceMessageCacheProtocol {
|
||||
} else {
|
||||
try FileManager.default.copyItem(at: source, to: destination)
|
||||
}
|
||||
try (destination as NSURL).setResourceValue(URLFileProtection.complete, forKey: .fileProtectionKey)
|
||||
}
|
||||
|
||||
private func cacheURL(for mediaSource: MediaSourceProxy) -> URL {
|
||||
|
||||
@@ -22,6 +22,9 @@ enum VoiceMessageCacheError: Error {
|
||||
}
|
||||
|
||||
protocol VoiceMessageCacheProtocol {
|
||||
/// URL to use for recording
|
||||
var urlForRecording: URL { get }
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -33,8 +33,12 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
private let mp4accMimeType = "audio/m4a"
|
||||
private let waveformSamplesCount = 100
|
||||
|
||||
var isRecording: Bool {
|
||||
audioRecorder.isRecording
|
||||
}
|
||||
|
||||
var recordingURL: URL? {
|
||||
audioRecorder.audioFileUrl
|
||||
audioRecorder.audioFileURL
|
||||
}
|
||||
|
||||
var recordingDuration: TimeInterval {
|
||||
@@ -67,9 +71,10 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
|
||||
func startRecording() async {
|
||||
await stopPlayback()
|
||||
previewAudioPlayer?.reset()
|
||||
recordingCancelled = false
|
||||
|
||||
await audioRecorder.record(with: .uuid(UUID()))
|
||||
await audioRecorder.record(audioFileURL: voiceMessageCache.urlForRecording)
|
||||
}
|
||||
|
||||
func stopRecording() async {
|
||||
@@ -78,22 +83,26 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
}
|
||||
|
||||
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 {
|
||||
guard let previewAudioPlayerState, let url = audioRecorder.audioFileURL else {
|
||||
return .failure(.previewNotAvailable)
|
||||
}
|
||||
|
||||
@@ -132,7 +141,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
}
|
||||
|
||||
func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> {
|
||||
guard let url = audioRecorder.audioFileUrl else {
|
||||
guard let url = audioRecorder.audioFileURL else {
|
||||
return .failure(.missingRecordingFile)
|
||||
}
|
||||
// build the waveform
|
||||
@@ -150,13 +159,18 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
}
|
||||
|
||||
func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError> {
|
||||
guard let url = audioRecorder.audioFileUrl else {
|
||||
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 {
|
||||
@@ -180,8 +194,6 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
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. \(error)")
|
||||
@@ -219,7 +231,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
actionsSubject.send(.didFailWithError(error: VoiceMessageRecorderError.previewNotAvailable))
|
||||
return
|
||||
}
|
||||
guard let recordingURL = audioRecorder.audioFileUrl, let previewAudioPlayerState else {
|
||||
guard let recordingURL = audioRecorder.audioFileURL, let previewAudioPlayerState else {
|
||||
actionsSubject.send(.didFailWithError(error: VoiceMessageRecorderError.previewNotAvailable))
|
||||
return
|
||||
}
|
||||
@@ -235,7 +247,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
|
||||
|
||||
private func finalizeRecording() async -> Result<Void, VoiceMessageRecorderError> {
|
||||
MXLog.info("finalize audio recording")
|
||||
guard let url = audioRecorder.audioFileUrl, audioRecorder.currentTime > 0 else {
|
||||
guard let url = audioRecorder.audioFileURL, audioRecorder.currentTime > 0 else {
|
||||
return .failure(.previewNotAvailable)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ enum VoiceMessageRecorderAction {
|
||||
protocol VoiceMessageRecorderProtocol {
|
||||
var audioRecorder: AudioRecorderProtocol { get }
|
||||
var previewAudioPlayerState: AudioPlayerState? { get }
|
||||
var isRecording: Bool { get }
|
||||
var recordingURL: URL? { get }
|
||||
var recordingDuration: TimeInterval { get }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user