Add UI to record a voice message from the composer toolbar (#1892)

This commit is contained in:
Nicolas Mauri
2023-10-16 17:18:51 +02:00
committed by GitHub
parent 0d72554e6f
commit 9ab3b20a49
43 changed files with 1241 additions and 87 deletions
@@ -0,0 +1,115 @@
//
// 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
import Combine
import Foundation
enum AudioRecorderError: Error {
case genericError
}
class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
private let silenceThreshold: Float = -50.0
private var audioRecorder: AVAudioRecorder?
private let actionsSubject: PassthroughSubject<AudioRecorderAction, Never> = .init()
var actions: AnyPublisher<AudioRecorderAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
var url: URL? {
audioRecorder?.url
}
var currentTime: TimeInterval {
audioRecorder?.currentTime ?? 0
}
var isRecording: Bool {
audioRecorder?.isRecording ?? false
}
func recordWithOutputURL(_ url: URL) {
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)
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
audioRecorder?.delegate = self
audioRecorder?.isMeteringEnabled = true
audioRecorder?.record()
actionsSubject.send(.didStartRecording)
} catch {
MXLog.error("audio recording failed: \(error)")
actionsSubject.send(.didFailWithError(error: error))
}
}
func stopRecording() {
audioRecorder?.stop()
do {
try AVAudioSession.sharedInstance().setActive(false)
} catch {
actionsSubject.send(.didFailWithError(error: error))
}
}
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))
}
// MARK: - AVAudioRecorderDelegate
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) {
if success {
actionsSubject.send(.didStopRecording)
} else {
actionsSubject.send(.didFailWithError(error: AudioRecorderError.genericError))
}
}
func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
actionsSubject.send(.didFailWithError(error: error ?? AudioRecorderError.genericError))
}
private func normalizedPowerLevelFromDecibels(_ decibels: Float) -> Float {
decibels / silenceThreshold
}
}
@@ -0,0 +1,38 @@
//
// 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 Combine
import Foundation
enum AudioRecorderAction {
case didStartRecording
case didStopRecording
case didFailWithError(error: Error)
}
protocol AudioRecorderProtocol: AnyObject {
var actions: AnyPublisher<AudioRecorderAction, Never> { get }
var currentTime: TimeInterval { get }
var isRecording: Bool { get }
var url: URL? { get }
func recordWithOutputURL(_ url: URL)
func stopRecording()
func averagePowerForChannelNumber(_ channelNumber: Int) -> Float
}
// sourcery: AutoMockable
extension AudioRecorderProtocol { }
@@ -0,0 +1,114 @@
//
// 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 Combine
import Foundation
import UIKit
enum AudioRecorderRecordingState {
case recording
case stopped
case error
}
@MainActor
class AudioRecorderState: ObservableObject, Identifiable {
let id = UUID()
@Published private(set) var recordingState: AudioRecorderRecordingState = .stopped
@Published private(set) var duration = 0.0
@Published private(set) var waveform = Waveform(data: Array(repeating: 0, count: 100))
private weak var audioRecorder: AudioRecorderProtocol?
private var cancellables: Set<AnyCancellable> = []
private var displayLink: CADisplayLink?
func attachAudioRecorder(_ audioRecorder: AudioRecorderProtocol) {
if self.audioRecorder != nil {
detachAudioRecorder()
}
recordingState = .stopped
self.audioRecorder = audioRecorder
subscribeToAudioRecorder(audioRecorder)
}
func detachAudioRecorder() {
guard audioRecorder != nil else { return }
audioRecorder?.stopRecording()
stopPublishUpdates()
cancellables = []
audioRecorder = nil
recordingState = .stopped
}
func reportError(_ error: Error) {
recordingState = .error
}
// MARK: - Private
private func subscribeToAudioRecorder(_ audioRecorder: AudioRecorderProtocol) {
audioRecorder.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
guard let self else {
return
}
self.handleAudioRecorderAction(action)
}
.store(in: &cancellables)
}
private func handleAudioRecorderAction(_ action: AudioRecorderAction) {
switch action {
case .didStartRecording:
startPublishUpdates()
recordingState = .recording
case .didStopRecording:
stopPublishUpdates()
recordingState = .stopped
case .didFailWithError:
stopPublishUpdates()
recordingState = .stopped
}
}
private func startPublishUpdates() {
if displayLink != nil {
stopPublishUpdates()
}
displayLink = CADisplayLink(target: self, selector: #selector(publishUpdate))
displayLink?.preferredFrameRateRange = .init(minimum: 10, maximum: 20)
displayLink?.add(to: .current, forMode: .common)
}
@objc private func publishUpdate(displayLink: CADisplayLink) {
if let currentTime = audioRecorder?.currentTime {
duration = currentTime
}
}
private func stopPublishUpdates() {
displayLink?.invalidate()
displayLink = nil
}
}
extension AudioRecorderState: Equatable {
nonisolated static func == (lhs: AudioRecorderState, rhs: AudioRecorderState) -> Bool {
lhs.id == rhs.id
}
}