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
@@ -3,6 +3,7 @@
// swiftlint:disable all
import AnalyticsEvents
import AVFoundation
import Combine
import Foundation
import LocalAuthentication
@@ -534,7 +535,7 @@ class AudioRecorderMock: AudioRecorderProtocol {
set(value) { underlyingIsRecording = value }
}
var underlyingIsRecording: Bool!
var url: URL?
var audioFileUrl: URL?
//MARK: - record
@@ -544,18 +545,13 @@ class AudioRecorderMock: AudioRecorderProtocol {
}
var recordWithReceivedRecordID: AudioRecordingIdentifier?
var recordWithReceivedInvocations: [AudioRecordingIdentifier] = []
var recordWithReturnValue: Result<Void, AudioRecorderError>!
var recordWithClosure: ((AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError>)?
var recordWithClosure: ((AudioRecordingIdentifier) async -> Void)?
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
func record(with recordID: AudioRecordingIdentifier) async {
recordWithCallsCount += 1
recordWithReceivedRecordID = recordID
recordWithReceivedInvocations.append(recordID)
if let recordWithClosure = recordWithClosure {
return await recordWithClosure(recordID)
} else {
return recordWithReturnValue
}
await recordWithClosure?(recordID)
}
//MARK: - stopRecording
@@ -581,28 +577,103 @@ class AudioRecorderMock: AudioRecorderProtocol {
deleteRecordingCallsCount += 1
await deleteRecordingClosure?()
}
//MARK: - averagePowerForChannelNumber
//MARK: - averagePower
var averagePowerForChannelNumberCallsCount = 0
var averagePowerForChannelNumberCalled: Bool {
return averagePowerForChannelNumberCallsCount > 0
var averagePowerCallsCount = 0
var averagePowerCalled: Bool {
return averagePowerCallsCount > 0
}
var averagePowerForChannelNumberReceivedChannelNumber: Int?
var averagePowerForChannelNumberReceivedInvocations: [Int] = []
var averagePowerForChannelNumberReturnValue: Float!
var averagePowerForChannelNumberClosure: ((Int) -> Float)?
var averagePowerReturnValue: Float!
var averagePowerClosure: (() -> Float)?
func averagePowerForChannelNumber(_ channelNumber: Int) -> Float {
averagePowerForChannelNumberCallsCount += 1
averagePowerForChannelNumberReceivedChannelNumber = channelNumber
averagePowerForChannelNumberReceivedInvocations.append(channelNumber)
if let averagePowerForChannelNumberClosure = averagePowerForChannelNumberClosure {
return averagePowerForChannelNumberClosure(channelNumber)
func averagePower() -> Float {
averagePowerCallsCount += 1
if let averagePowerClosure = averagePowerClosure {
return averagePowerClosure()
} else {
return averagePowerForChannelNumberReturnValue
return averagePowerReturnValue
}
}
}
class AudioSessionMock: AudioSessionProtocol {
//MARK: - requestRecordPermission
var requestRecordPermissionCallsCount = 0
var requestRecordPermissionCalled: Bool {
return requestRecordPermissionCallsCount > 0
}
var requestRecordPermissionReceivedResponse: ((Bool) -> Void)?
var requestRecordPermissionReceivedInvocations: [((Bool) -> Void)] = []
var requestRecordPermissionClosure: ((@escaping (Bool) -> Void) -> Void)?
func requestRecordPermission(_ response: @escaping (Bool) -> Void) {
requestRecordPermissionCallsCount += 1
requestRecordPermissionReceivedResponse = response
requestRecordPermissionReceivedInvocations.append(response)
requestRecordPermissionClosure?(response)
}
//MARK: - setAllowHapticsAndSystemSoundsDuringRecording
var setAllowHapticsAndSystemSoundsDuringRecordingThrowableError: Error?
var setAllowHapticsAndSystemSoundsDuringRecordingCallsCount = 0
var setAllowHapticsAndSystemSoundsDuringRecordingCalled: Bool {
return setAllowHapticsAndSystemSoundsDuringRecordingCallsCount > 0
}
var setAllowHapticsAndSystemSoundsDuringRecordingReceivedInValue: Bool?
var setAllowHapticsAndSystemSoundsDuringRecordingReceivedInvocations: [Bool] = []
var setAllowHapticsAndSystemSoundsDuringRecordingClosure: ((Bool) throws -> Void)?
func setAllowHapticsAndSystemSoundsDuringRecording(_ inValue: Bool) throws {
if let error = setAllowHapticsAndSystemSoundsDuringRecordingThrowableError {
throw error
}
setAllowHapticsAndSystemSoundsDuringRecordingCallsCount += 1
setAllowHapticsAndSystemSoundsDuringRecordingReceivedInValue = inValue
setAllowHapticsAndSystemSoundsDuringRecordingReceivedInvocations.append(inValue)
try setAllowHapticsAndSystemSoundsDuringRecordingClosure?(inValue)
}
//MARK: - setCategory
var setCategoryModeOptionsThrowableError: Error?
var setCategoryModeOptionsCallsCount = 0
var setCategoryModeOptionsCalled: Bool {
return setCategoryModeOptionsCallsCount > 0
}
var setCategoryModeOptionsReceivedArguments: (category: AVAudioSession.Category, mode: AVAudioSession.Mode, options: AVAudioSession.CategoryOptions)?
var setCategoryModeOptionsReceivedInvocations: [(category: AVAudioSession.Category, mode: AVAudioSession.Mode, options: AVAudioSession.CategoryOptions)] = []
var setCategoryModeOptionsClosure: ((AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions) throws -> Void)?
func setCategory(_ category: AVAudioSession.Category, mode: AVAudioSession.Mode, options: AVAudioSession.CategoryOptions) throws {
if let error = setCategoryModeOptionsThrowableError {
throw error
}
setCategoryModeOptionsCallsCount += 1
setCategoryModeOptionsReceivedArguments = (category: category, mode: mode, options: options)
setCategoryModeOptionsReceivedInvocations.append((category: category, mode: mode, options: options))
try setCategoryModeOptionsClosure?(category, mode, options)
}
//MARK: - setActive
var setActiveOptionsThrowableError: Error?
var setActiveOptionsCallsCount = 0
var setActiveOptionsCalled: Bool {
return setActiveOptionsCallsCount > 0
}
var setActiveOptionsReceivedArguments: (active: Bool, options: AVAudioSession.SetActiveOptions)?
var setActiveOptionsReceivedInvocations: [(active: Bool, options: AVAudioSession.SetActiveOptions)] = []
var setActiveOptionsClosure: ((Bool, AVAudioSession.SetActiveOptions) throws -> Void)?
func setActive(_ active: Bool, options: AVAudioSession.SetActiveOptions) throws {
if let error = setActiveOptionsThrowableError {
throw error
}
setActiveOptionsCallsCount += 1
setActiveOptionsReceivedArguments = (active: active, options: options)
setActiveOptionsReceivedInvocations.append((active: active, options: options))
try setActiveOptionsClosure?(active, options)
}
}
class BugReportServiceMock: BugReportServiceProtocol {
var isRunning: Bool {
get { return underlyingIsRunning }
@@ -3168,6 +3239,11 @@ class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol {
set(value) { underlyingRecordingDuration = value }
}
var underlyingRecordingDuration: TimeInterval!
var actions: AnyPublisher<VoiceMessageRecorderAction, Never> {
get { return underlyingActions }
set(value) { underlyingActions = value }
}
var underlyingActions: AnyPublisher<VoiceMessageRecorderAction, Never>!
//MARK: - startRecording
@@ -3175,16 +3251,11 @@ class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol {
var startRecordingCalled: Bool {
return startRecordingCallsCount > 0
}
var startRecordingReturnValue: Result<Void, VoiceMessageRecorderError>!
var startRecordingClosure: (() async -> Result<Void, VoiceMessageRecorderError>)?
var startRecordingClosure: (() async -> Void)?
func startRecording() async -> Result<Void, VoiceMessageRecorderError> {
func startRecording() async {
startRecordingCallsCount += 1
if let startRecordingClosure = startRecordingClosure {
return await startRecordingClosure()
} else {
return startRecordingReturnValue
}
await startRecordingClosure?()
}
//MARK: - stopRecording
@@ -3192,16 +3263,11 @@ class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol {
var stopRecordingCalled: Bool {
return stopRecordingCallsCount > 0
}
var stopRecordingReturnValue: Result<Void, VoiceMessageRecorderError>!
var stopRecordingClosure: (() async -> Result<Void, VoiceMessageRecorderError>)?
var stopRecordingClosure: (() async -> Void)?
func stopRecording() async -> Result<Void, VoiceMessageRecorderError> {
func stopRecording() async {
stopRecordingCallsCount += 1
if let stopRecordingClosure = stopRecordingClosure {
return await stopRecordingClosure()
} else {
return stopRecordingReturnValue
}
await stopRecordingClosure?()
}
//MARK: - cancelRecording
@@ -42,6 +42,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private var canCurrentUserRedact = false
private var paginateBackwardsTask: Task<Void, Never>?
private var resumeVoiceMessagePlaybackAfterScrubbing = false
private var voiceMessageRecorderObserver: AnyCancellable?
init(timelineController: RoomTimelineControllerProtocol,
mediaProvider: MediaProviderProtocol,
@@ -935,19 +936,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - Voice message
private func stopVoiceMessageRecorder() async {
_ = await voiceMessageRecorder.stopRecording()
await voiceMessageRecorder.stopPlayback()
}
private func startRecordingVoiceMessage() async {
let audioRecordState = AudioRecorderState()
audioRecordState.attachAudioRecorder(voiceMessageRecorder.audioRecorder)
switch await voiceMessageRecorder.startRecording() {
case .success:
private func handleVoiceMessageRecorderAction(_ action: VoiceMessageRecorderAction) {
MXLog.debug("handling voice recorder action: \(action) - (audio)")
switch action {
case .didStartRecording(let audioRecorder):
let audioRecordState = AudioRecorderState()
audioRecordState.attachAudioRecorder(audioRecorder)
actionsSubject.send(.composer(action: .setMode(mode: .recordVoiceMessage(state: audioRecordState))))
case .failure(let error):
case .didStopRecording(let previewAudioPlayerState, let url):
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: previewAudioPlayerState, waveform: .url(url), isUploading: false))))
case .didFailWithError(let error):
switch error {
case .audioRecorderError(.recordPermissionNotGranted):
MXLog.info("permission to record audio has not been granted.")
@@ -958,37 +956,34 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
secondaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil))
default:
MXLog.error("failed to start voice message recording. \(error)")
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
}
}
private func startRecordingVoiceMessage() async {
voiceMessageRecorderObserver = voiceMessageRecorder.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
self?.handleVoiceMessageRecorderAction(action)
}
await voiceMessageRecorder.startRecording()
}
private func stopRecordingVoiceMessage() async {
if case .failure(let error) = await voiceMessageRecorder.stopRecording() {
MXLog.error("failed to stop the recording. \(error)")
return
}
guard let audioPlayerState = voiceMessageRecorder.previewAudioPlayerState else {
MXLog.error("the recorder preview is missing after the recording has been stopped")
return
}
guard let recordingURL = voiceMessageRecorder.recordingURL else {
MXLog.error("the recording URL is missing after the recording has been stopped")
return
}
mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL), isUploading: false))))
await voiceMessageRecorder.stopRecording()
}
private func cancelRecordingVoiceMessage() async {
await voiceMessageRecorder.cancelRecording()
voiceMessageRecorderObserver = nil
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
private func deleteCurrentVoiceMessage() async {
await voiceMessageRecorder.deleteRecording()
voiceMessageRecorderObserver = nil
actionsSubject.send(.composer(action: .setMode(mode: .default)))
}
@@ -0,0 +1,39 @@
//
// 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 AVFoundation
protocol AudioSessionProtocol: AnyObject {
func requestRecordPermission(_ response: @escaping (Bool) -> Void)
func setAllowHapticsAndSystemSoundsDuringRecording(_ inValue: Bool) throws
func setCategory(_ category: AVAudioSession.Category, mode: AVAudioSession.Mode, options: AVAudioSession.CategoryOptions) throws
func setActive(_ active: Bool, options: AVAudioSession.SetActiveOptions) throws
}
extension AudioSessionProtocol {
func setCategory(_ category: AVAudioSession.Category, mode: AVAudioSession.Mode) throws {
try setCategory(category, mode: mode, options: [])
}
func setActive(_ active: Bool) throws {
try setActive(active, options: [])
}
}
// sourcery: AutoMockable
extension AudioSessionProtocol { }
extension AVAudioSession: AudioSessionProtocol { }
@@ -14,15 +14,25 @@
// limitations under the License.
//
import Accelerate
import AVFoundation
import Combine
import Foundation
import UIKit
class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
private let silenceThreshold: Float = -50.0
private var audioRecorder: AVAudioRecorder?
private enum InternalAudioRecorderState: Equatable {
case recording
case suspended
case stopped
case error(AudioRecorderError)
}
class AudioRecorder: AudioRecorderProtocol {
private let audioSession: AudioSessionProtocol
private var audioEngine: AVAudioEngine?
private var mixer: AVAudioMixerNode?
private var audioFile: AVAudioFile?
private var internalState = InternalAudioRecorderState.stopped
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<AudioRecorderAction, Never> = .init()
@@ -30,34 +40,35 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
actionsSubject.eraseToAnyPublisher()
}
var url: URL? {
audioRecorder?.url
}
var currentTime: TimeInterval {
audioRecorder?.currentTime ?? 0
}
private let silenceThreshold: Float = -50.0
private var meterLevel: Float = 0
private(set) var audioFileUrl: URL?
var currentTime: TimeInterval = .zero
var isRecording: Bool {
audioRecorder?.isRecording ?? false
audioEngine?.isRunning ?? false
}
private let dispatchQueue = DispatchQueue(label: "io.element.elementx.audio_recorder", qos: .userInitiated)
private var stopped = false
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
init(audioSession: AudioSessionProtocol = AVAudioSession.sharedInstance()) {
self.audioSession = audioSession
}
func record(with recordID: AudioRecordingIdentifier) async {
stopped = false
guard await requestRecordPermission() else {
return .failure(.recordPermissionNotGranted)
setInternalState(.error(.recordPermissionNotGranted))
return
}
let result = await startRecording(with: recordID)
switch result {
case .success:
actionsSubject.send(.didStartRecording)
setInternalState(.recording)
case .failure(let error):
actionsSubject.send(.didFailWithError(error: error))
setInternalState(.error(error))
}
return result
}
func stopRecording() async {
@@ -68,6 +79,11 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
}
}
func cancelRecording() async {
await stopRecording()
await deleteRecording()
}
func deleteRecording() async {
await withCheckedContinuation { continuation in
deleteRecording {
@@ -76,49 +92,37 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
}
}
func peakPowerForChannelNumber(_ channelNumber: Int) -> Float {
guard isRecording, let audioRecorder else {
return 0.0
}
audioRecorder.updateMeters()
return normalizedPowerLevelFromDecibels(audioRecorder.peakPower(forChannel: channelNumber))
}
func averagePowerForChannelNumber(_ channelNumber: Int) -> Float {
guard isRecording, let audioRecorder else {
return 0.0
}
audioRecorder.updateMeters()
return normalizedPowerLevelFromDecibels(audioRecorder.averagePower(forChannel: channelNumber))
func averagePower() -> Float {
meterLevel
}
// MARK: - Private
private func addObservers() {
// Stop recording uppon UIApplication.didEnterBackgroundNotification notification
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
.sink { [weak self] _ in
guard let self else { return }
Task { await self.stopRecording() }
}
.store(in: &cancellables)
}
private func removeObservers() {
cancellables.removeAll()
}
private func requestRecordPermission() async -> Bool {
await withCheckedContinuation { continuation in
AVAudioSession.sharedInstance().requestRecordPermission { granted in
audioSession.requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
}
// MARK: - Private
private func setupAudioSession() {
MXLog.info("setup audio session")
do {
try audioSession.setAllowHapticsAndSystemSoundsDuringRecording(true)
try audioSession.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth])
try audioSession.setActive(true)
} catch {
MXLog.error("Could not redirect audio playback to speakers.")
}
addObservers()
}
private func releaseAudioSession() {
MXLog.info("releasing audio session")
try? audioSession.setActive(false)
removeObservers()
}
private func startRecording(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
await withCheckedContinuation { continuation in
@@ -128,37 +132,64 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
}
}
private func createAudioFile(with recordID: AudioRecordingIdentifier, 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)
}
private func startRecording(with recordID: AudioRecordingIdentifier, completion: @escaping (Result<Void, AudioRecorderError>) -> Void) {
dispatchQueue.async { [weak self] in
guard let self, !self.stopped else {
completion(.failure(.recordingCancelled))
return
}
let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 48000,
AVEncoderBitRateKey: 128_000,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
setupAudioSession()
let audioEngine = AVAudioEngine()
self.audioEngine = audioEngine
// The sample rate must match the hardware sample rate for the audio engine to work.
let sampleRate = audioEngine.inputNode.inputFormat(forBus: 0).sampleRate
let recordingFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32,
sampleRate: sampleRate,
channels: 1,
interleaved: false)
// Make sure we have 1 channel at the end by using a mixer.
let mixer = AVAudioMixerNode()
self.mixer = mixer
audioEngine.attach(mixer)
audioEngine.connect(audioEngine.inputNode, to: mixer, format: recordingFormat)
// Reset the recording duration
currentTime = 0
let audioFile: AVAudioFile
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setAllowHapticsAndSystemSoundsDuringRecording(true)
try audioSession.setCategory(.playAndRecord, mode: .default)
try audioSession.setActive(true)
let url = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a")
let audioRecorder = try AVAudioRecorder(url: url, settings: settings)
audioRecorder.delegate = self
audioRecorder.isMeteringEnabled = true
if audioRecorder.record() {
self.audioRecorder = audioRecorder
completion(.success(()))
} else {
MXLog.error("audio recording failed to start")
completion(.failure(.recordingFailed))
}
audioFile = try createAudioFile(with: recordID, sampleRate: Int(sampleRate))
self.audioFile = audioFile
audioFileUrl = audioFile.url
} catch {
MXLog.error("failed to create an audio file. \(error)")
completion(.failure(.audioFileCreationFailure))
releaseAudioSession()
return
}
mixer.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer)
}
do {
try audioEngine.start()
completion(.success(()))
} catch {
MXLog.error("audio recording failed to start. \(error)")
completion(.failure(.internalError(error: error)))
completion(.failure(.audioEngineFailure))
}
}
}
@@ -170,42 +201,186 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
}
guard let self else { return }
stopped = true
guard let audioRecorder, audioRecorder.isRecording else {
return
}
audioRecorder.stop()
cleanupAudioEngine()
MXLog.info("audio recorder stopped")
setInternalState(.stopped)
}
}
private func cleanupAudioEngine() {
if let audioEngine {
audioEngine.stop()
if let mixer {
mixer.removeTap(onBus: 0)
audioEngine.detach(mixer)
}
}
audioFile = nil // this will close the file
audioEngine = nil
releaseAudioSession()
}
private func deleteRecording(completion: @escaping () -> Void) {
dispatchQueue.async { [weak self] in
defer {
completion()
}
guard let self else { return }
audioRecorder?.deleteRecording()
if let audioFileUrl {
try? FileManager.default.removeItem(at: audioFileUrl)
}
audioFileUrl = nil
currentTime = 0
}
}
// MARK: - AVAudioRecorderDelegate
// MARK: Audio Processing
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) {
try? AVAudioSession.sharedInstance().setActive(false)
if success {
actionsSubject.send(.didStopRecording)
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
// Write the buffer into the audio file
do {
try audioFile?.write(from: buffer)
// Compute the sample value for the waveform
updateMeterLevel(buffer)
// Update the recording duration only if we succeed to write the buffer
currentTime += Double(buffer.frameLength) / buffer.format.sampleRate
} catch {
MXLog.error("failed to write sample. \(error)")
}
}
// MARK: Observers
private func addObservers() {
removeObservers()
// Stop recording uppon UIApplication.didEnterBackgroundNotification notification
NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)
.sink { [weak self] _ in
guard let self else { return }
MXLog.warning("Application will resign active while recording.")
Task { await self.stopRecording() }
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name.AVAudioEngineConfigurationChange)
.sink { [weak self] notification in
guard let self else { return }
self.handleConfigurationChange(notification: notification)
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification)
.sink { [weak self] notification in
guard let self else { return }
self.handleInterruption(notification: notification)
}
.store(in: &cancellables)
}
private func removeObservers() {
cancellables.removeAll()
}
func handleInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
MXLog.info("Interruption started: \(notification)")
setInternalState(.suspended)
case .ended:
MXLog.info("Interruption ended: \(notification)")
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
do {
try audioEngine?.start()
setInternalState(.recording)
} catch {
MXLog.debug("Error restarting audio: \(error)")
setInternalState(.error(.interrupted))
}
} else {
MXLog.warning("AudioSession was interrupted: \(notification)")
setInternalState(.error(.interrupted))
}
@unknown default:
break
}
}
func handleConfigurationChange(notification: Notification) {
guard let audioEngine else { return }
MXLog.warning("Configuration changed: \(audioEngine.inputNode.inputFormat(forBus: 0))")
if internalState != .suspended {
Task { await stopRecording() }
}
}
// MARK: Internal State
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
switch internalState {
case .recording:
actionsSubject.send(.didStartRecording)
case .suspended:
break
case .stopped:
actionsSubject.send(.didStopRecording)
case .error(let error):
cleanupAudioEngine()
actionsSubject.send(.didFailWithError(error: error))
}
}
}
// MARK: Audio Metering
private func scaledPower(power: Float) -> Float {
guard power.isFinite else {
return 0.0
}
let minDb: Float = silenceThreshold
if power < minDb {
return 0.0
} else if power >= 1.0 {
return 1.0
} else {
MXLog.error("audio recorder did finish recording with an error.")
actionsSubject.send(.didFailWithError(error: AudioRecorderError.genericError))
return (abs(minDb) - abs(power)) / abs(minDb)
}
}
func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
try? AVAudioSession.sharedInstance().setActive(false)
MXLog.error("audio recorder encode error did occur. \(error?.localizedDescription ?? "")")
actionsSubject.send(.didFailWithError(error: error ?? AudioRecorderError.genericError))
}
private func normalizedPowerLevelFromDecibels(_ decibels: Float) -> Float {
decibels / silenceThreshold
private func updateMeterLevel(_ buffer: AVAudioPCMBuffer) {
// Get an array of pointer to each sample's data
guard let channelData = buffer.floatChannelData else {
return
}
// Compute RMS
var rms: Float = .nan
vDSP_rmsqv(channelData.pointee, buffer.stride, &rms, vDSP_Length(buffer.frameLength))
// Convert to decibels
let avgPower = 20 * log10(rms)
meterLevel = scaledPower(power: avgPower)
}
}
@@ -30,30 +30,31 @@ extension AudioRecordingIdentifier {
}
}
enum AudioRecorderError: Error {
case genericError
case internalError(error: Error)
case recordPermissionNotGranted
case recordingFailed
enum AudioRecorderError: Error, Equatable {
case audioEngineFailure
case audioFileCreationFailure
case interrupted
case recordingCancelled
case recordingFailed
case recordPermissionNotGranted
}
enum AudioRecorderAction {
case didStartRecording
case didStopRecording
case didFailWithError(error: Error)
case didFailWithError(error: AudioRecorderError)
}
protocol AudioRecorderProtocol: AnyObject {
var actions: AnyPublisher<AudioRecorderAction, Never> { get }
var currentTime: TimeInterval { get }
var isRecording: Bool { get }
var url: URL? { get }
var audioFileUrl: URL? { get }
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError>
func record(with recordID: AudioRecordingIdentifier) async
func stopRecording() async
func deleteRecording() async
func averagePowerForChannelNumber(_ channelNumber: Int) -> Float
func averagePower() -> Float
}
// sourcery: AutoMockable
@@ -101,8 +101,8 @@ class AudioRecorderState: ObservableObject, Identifiable {
if let currentTime = audioRecorder?.currentTime {
duration = currentTime
}
if let sample = audioRecorder?.averagePowerForChannelNumber(0) {
waveformSamples.append(sample)
if let averagePower = audioRecorder?.averagePower() {
waveformSamples.append(1.0 - averagePower)
}
}
@@ -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()