Fix playback and recording of voice messages (#1948)

This commit is contained in:
Nicolas Mauri 2023-10-24 18:49:42 +02:00 committed by GitHub
parent 37f1a79a11
commit f9ad44f96a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 115 additions and 55 deletions

View File

@ -447,11 +447,11 @@ class AudioRecorderMock: AudioRecorderProtocol {
var deleteRecordingCalled: Bool { var deleteRecordingCalled: Bool {
return deleteRecordingCallsCount > 0 return deleteRecordingCallsCount > 0
} }
var deleteRecordingClosure: (() -> Void)? var deleteRecordingClosure: (() async -> Void)?
func deleteRecording() { func deleteRecording() async {
deleteRecordingCallsCount += 1 deleteRecordingCallsCount += 1
deleteRecordingClosure?() await deleteRecordingClosure?()
} }
//MARK: - averagePowerForChannelNumber //MARK: - averagePowerForChannelNumber

View File

@ -243,7 +243,7 @@ struct ComposerToolbar: View {
} stopRecording: { } stopRecording: {
if let voiceMessageRecordingStartTime, Date.now.timeIntervalSince(voiceMessageRecordingStartTime) < voiceMessageMinimumRecordingDuration { if let voiceMessageRecordingStartTime, Date.now.timeIntervalSince(voiceMessageRecordingStartTime) < voiceMessageMinimumRecordingDuration {
context.send(viewAction: .cancelVoiceMessageRecording) context.send(viewAction: .cancelVoiceMessageRecording)
withAnimation { withElementAnimation {
showVoiceMessageRecordingTooltip = true showVoiceMessageRecordingTooltip = true
} }
} else { } else {
@ -273,7 +273,7 @@ struct ComposerToolbar: View {
.allowsHitTesting(false) .allowsHitTesting(false)
.onAppear { .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + voiceMessageTooltipDuration) { DispatchQueue.main.asyncAfter(deadline: .now() + voiceMessageTooltipDuration) {
withAnimation { withElementAnimation {
showVoiceMessageRecordingTooltip = false showVoiceMessageRecordingTooltip = false
} }
} }

View File

@ -42,42 +42,38 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
audioRecorder?.isRecording ?? false audioRecorder?.isRecording ?? 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> { func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
stopped = false
guard await requestRecordPermission() else { guard await requestRecordPermission() else {
return .failure(.recordPermissionNotGranted) return .failure(.recordPermissionNotGranted)
} }
let result = await startRecording(with: recordID)
let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC), switch result {
AVSampleRateKey: 48000, case .success:
AVEncoderBitRateKey: 128_000,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
do {
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
let url = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a")
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
audioRecorder?.delegate = self
audioRecorder?.isMeteringEnabled = true
audioRecorder?.record()
actionsSubject.send(.didStartRecording) actionsSubject.send(.didStartRecording)
} catch { case .failure(let error):
MXLog.error("audio recording failed: \(error)")
actionsSubject.send(.didFailWithError(error: error)) actionsSubject.send(.didFailWithError(error: error))
} }
return .success(()) return result
} }
func stopRecording() async { func stopRecording() async {
guard let audioRecorder, audioRecorder.isRecording else { await withCheckedContinuation { continuation in
return stopRecording {
continuation.resume()
}
} }
audioRecorder.stop()
} }
func deleteRecording() { func deleteRecording() async {
audioRecorder?.deleteRecording() await withCheckedContinuation { continuation in
deleteRecording {
continuation.resume()
}
}
} }
func peakPowerForChannelNumber(_ channelNumber: Int) -> Float { func peakPowerForChannelNumber(_ channelNumber: Int) -> Float {
@ -121,7 +117,69 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
} }
} }
} }
// MARK: - Private
private func startRecording(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
await withCheckedContinuation { continuation in
startRecording(with: recordID) { result in
continuation.resume(returning: result)
}
}
}
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]
do {
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
let url = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a")
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
audioRecorder?.delegate = self
audioRecorder?.isMeteringEnabled = true
audioRecorder?.record()
completion(.success(()))
} catch {
MXLog.error("audio recording failed: \(error)")
completion(.failure(.genericError))
}
}
}
private func stopRecording(completion: @escaping () -> Void) {
dispatchQueue.async { [weak self] in
defer {
completion()
}
guard let self else { return }
stopped = true
guard let audioRecorder, audioRecorder.isRecording else {
return
}
audioRecorder.stop()
}
}
private func deleteRecording(completion: @escaping () -> Void) {
dispatchQueue.async { [weak self] in
defer {
completion()
}
guard let self else { return }
audioRecorder?.deleteRecording()
}
}
// MARK: - AVAudioRecorderDelegate // MARK: - AVAudioRecorderDelegate
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) { func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) {

View File

@ -33,6 +33,7 @@ extension AudioRecordingIdentifier {
enum AudioRecorderError: Error { enum AudioRecorderError: Error {
case genericError case genericError
case recordPermissionNotGranted case recordPermissionNotGranted
case recordingCancelled
} }
enum AudioRecorderAction { enum AudioRecorderAction {
@ -49,7 +50,7 @@ protocol AudioRecorderProtocol: AnyObject {
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError>
func stopRecording() async func stopRecording() async
func deleteRecording() func deleteRecording() async
func averagePowerForChannelNumber(_ channelNumber: Int) -> Float func averagePowerForChannelNumber(_ channelNumber: Int) -> Float
} }

View File

@ -48,7 +48,6 @@ class MediaPlayerProvider: MediaPlayerProviderProtocol {
return audioPlayerStates[audioPlayerStateID] return audioPlayerStates[audioPlayerStateID]
} }
@MainActor
func register(audioPlayerState: AudioPlayerState) { func register(audioPlayerState: AudioPlayerState) {
guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else { guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else {
MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)") MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)")
@ -57,7 +56,6 @@ class MediaPlayerProvider: MediaPlayerProviderProtocol {
audioPlayerStates[audioPlayerStateID] = audioPlayerState audioPlayerStates[audioPlayerStateID] = audioPlayerState
} }
@MainActor
func unregister(audioPlayerState: AudioPlayerState) { func unregister(audioPlayerState: AudioPlayerState) {
guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else { guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else {
MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)") MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)")
@ -66,12 +64,12 @@ class MediaPlayerProvider: MediaPlayerProviderProtocol {
audioPlayerStates[audioPlayerStateID] = nil audioPlayerStates[audioPlayerStateID] = nil
} }
func detachAllStates(except exception: AudioPlayerState?) async { func detachAllStates(except exception: AudioPlayerState?) {
for key in audioPlayerStates.keys { for key in audioPlayerStates.keys {
if let exception, key == audioPlayerStateID(for: exception.id) { if let exception, key == audioPlayerStateID(for: exception.id) {
continue continue
} }
await audioPlayerStates[key]?.detachAudioPlayer() audioPlayerStates[key]?.detachAudioPlayer()
} }
} }

View File

@ -20,6 +20,7 @@ enum MediaPlayerProviderError: Error {
case unsupportedMediaType case unsupportedMediaType
} }
@MainActor
protocol MediaPlayerProviderProtocol { protocol MediaPlayerProviderProtocol {
func player(for mediaSource: MediaSourceProxy) -> Result<MediaPlayerProtocol, MediaPlayerProviderError> func player(for mediaSource: MediaSourceProxy) -> Result<MediaPlayerProtocol, MediaPlayerProviderError>

View File

@ -309,6 +309,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
if audioPlayer.state == .playing { if audioPlayer.state == .playing {
audioPlayer.pause() audioPlayer.pause()
} else { } else {
audioPlayerState.attachAudioPlayer(audioPlayer)
audioPlayer.play() audioPlayer.play()
} }
} }

View File

@ -68,14 +68,14 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
func cancelRecording() async { func cancelRecording() async {
await audioRecorder.stopRecording() await audioRecorder.stopRecording()
audioRecorder.deleteRecording() await audioRecorder.deleteRecording()
recordingURL = nil recordingURL = nil
previewAudioPlayerState = nil previewAudioPlayerState = nil
} }
func deleteRecording() async { func deleteRecording() async {
await stopPlayback() await stopPlayback()
audioRecorder.deleteRecording() await audioRecorder.deleteRecording()
previewAudioPlayerState = nil previewAudioPlayerState = nil
recordingURL = nil recordingURL = nil
} }
@ -189,7 +189,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
// Build the preview audio player // Build the preview audio player
let mediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType) let mediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType)
guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: mediaSource), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else { guard case .success(let mediaPlayer) = await mediaPlayerProvider.player(for: mediaSource), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
return .failure(.previewNotAvailable) return .failure(.previewNotAvailable)
} }
previewAudioPlayer = audioPlayer previewAudioPlayer = audioPlayer

View File

@ -19,6 +19,7 @@ import Combine
import Foundation import Foundation
import XCTest import XCTest
@MainActor
class MediaPlayerProviderTests: XCTestCase { class MediaPlayerProviderTests: XCTestCase {
private var mediaPlayerProvider: MediaPlayerProvider! private var mediaPlayerProvider: MediaPlayerProvider!
@ -41,7 +42,7 @@ class MediaPlayerProviderTests: XCTestCase {
} }
let mediaSourceVideo = MediaSourceProxy(url: someURL, mimeType: "video/mp4") let mediaSourceVideo = MediaSourceProxy(url: someURL, mimeType: "video/mp4")
switch mediaPlayerProvider.player(for: mediaSourceWithoutMimeType) { switch mediaPlayerProvider.player(for: mediaSourceVideo) {
case .failure(.unsupportedMediaType): case .failure(.unsupportedMediaType):
// Ok // Ok
break break
@ -72,11 +73,11 @@ class MediaPlayerProviderTests: XCTestCase {
// By default, there should be no player state // By default, there should be no player state
XCTAssertNil(mediaPlayerProvider.playerState(for: audioPlayerStateId)) XCTAssertNil(mediaPlayerProvider.playerState(for: audioPlayerStateId))
let audioPlayerState = await AudioPlayerState(id: audioPlayerStateId, duration: 10.0) let audioPlayerState = AudioPlayerState(id: audioPlayerStateId, duration: 10.0)
await mediaPlayerProvider.register(audioPlayerState: audioPlayerState) mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
XCTAssertEqual(audioPlayerState, mediaPlayerProvider.playerState(for: audioPlayerStateId)) XCTAssertEqual(audioPlayerState, mediaPlayerProvider.playerState(for: audioPlayerStateId))
await mediaPlayerProvider.unregister(audioPlayerState: audioPlayerState) mediaPlayerProvider.unregister(audioPlayerState: audioPlayerState)
XCTAssertNil(mediaPlayerProvider.playerState(for: audioPlayerStateId)) XCTAssertNil(mediaPlayerProvider.playerState(for: audioPlayerStateId))
} }
@ -84,17 +85,17 @@ class MediaPlayerProviderTests: XCTestCase {
let audioPlayer = AudioPlayerMock() let audioPlayer = AudioPlayerMock()
audioPlayer.actions = PassthroughSubject<AudioPlayerAction, Never>().eraseToAnyPublisher() audioPlayer.actions = PassthroughSubject<AudioPlayerAction, Never>().eraseToAnyPublisher()
let audioPlayerStates = await Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 0), count: 10) let audioPlayerStates = Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 0), count: 10)
for audioPlayerState in audioPlayerStates { for audioPlayerState in audioPlayerStates {
await mediaPlayerProvider.register(audioPlayerState: audioPlayerState) mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
await audioPlayerState.attachAudioPlayer(audioPlayer) audioPlayerState.attachAudioPlayer(audioPlayer)
let isAttached = await audioPlayerState.isAttached let isAttached = audioPlayerState.isAttached
XCTAssertTrue(isAttached) XCTAssertTrue(isAttached)
} }
await mediaPlayerProvider.detachAllStates(except: nil) mediaPlayerProvider.detachAllStates(except: nil)
for audioPlayerState in audioPlayerStates { for audioPlayerState in audioPlayerStates {
let isAttached = await audioPlayerState.isAttached let isAttached = audioPlayerState.isAttached
XCTAssertFalse(isAttached) XCTAssertFalse(isAttached)
} }
} }
@ -103,18 +104,18 @@ class MediaPlayerProviderTests: XCTestCase {
let audioPlayer = AudioPlayerMock() let audioPlayer = AudioPlayerMock()
audioPlayer.actions = PassthroughSubject<AudioPlayerAction, Never>().eraseToAnyPublisher() audioPlayer.actions = PassthroughSubject<AudioPlayerAction, Never>().eraseToAnyPublisher()
let audioPlayerStates = await Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 0), count: 10) let audioPlayerStates = Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 0), count: 10)
for audioPlayerState in audioPlayerStates { for audioPlayerState in audioPlayerStates {
await mediaPlayerProvider.register(audioPlayerState: audioPlayerState) mediaPlayerProvider.register(audioPlayerState: audioPlayerState)
await audioPlayerState.attachAudioPlayer(audioPlayer) audioPlayerState.attachAudioPlayer(audioPlayer)
let isAttached = await audioPlayerState.isAttached let isAttached = audioPlayerState.isAttached
XCTAssertTrue(isAttached) XCTAssertTrue(isAttached)
} }
let exception = audioPlayerStates[1] let exception = audioPlayerStates[1]
await mediaPlayerProvider.detachAllStates(except: exception) mediaPlayerProvider.detachAllStates(except: exception)
for audioPlayerState in audioPlayerStates { for audioPlayerState in audioPlayerStates {
let isAttached = await audioPlayerState.isAttached let isAttached = audioPlayerState.isAttached
if audioPlayerState == exception { if audioPlayerState == exception {
XCTAssertTrue(isAttached) XCTAssertTrue(isAttached)
} else { } else {