mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user