First part of the voice broadcast recording feature

This commit is contained in:
Philippe Loriaux
2022-10-19 18:25:29 +02:00
parent 4dc474cee1
commit 295580d3e8
28 changed files with 1036 additions and 0 deletions
@@ -0,0 +1,65 @@
//
// Copyright 2022 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 SwiftUI
import UIKit
import AVFoundation
struct VoiceBroadcastRecorderCoordinatorParameters {
let session: MXSession
let room: MXRoom
let voiceBroadcastStartEvent: MXEvent
}
final class VoiceBroadcastRecorderCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: VoiceBroadcastRecorderCoordinatorParameters
private var voiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol
private var voiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelProtocol
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
// MARK: - Setup
init(parameters: VoiceBroadcastRecorderCoordinatorParameters) {
self.parameters = parameters
voiceBroadcastRecorderService = VoiceBroadcastRecorderService(session: parameters.session, roomId: parameters.room.matrixItemId)
let viewModel = VoiceBroadcastRecorderViewModel(recorderService: voiceBroadcastRecorderService)
voiceBroadcastRecorderViewModel = viewModel
}
// MARK: - Public
func start() { }
func toPresentable() -> UIViewController {
VectorHostingController(rootView: VoiceBroadcastRecorderView(viewModel: voiceBroadcastRecorderViewModel.context),
forceZeroSafeAreaInsets: true)
}
// MARK: - Private
}
@@ -0,0 +1,58 @@
//
// Copyright 2022 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 Foundation
import AVFoundation
class VoiceBroadcastRecorderProvider {
// MARK: - Constants
static let shared = VoiceBroadcastRecorderProvider()
// MARK: - Properties
// MARK: Public
var session: MXSession?
var coordinatorsForEventIdentifiers = [String: VoiceBroadcastRecorderCoordinator]()
// MARK: - Setup
private init() { }
// MARK: - Public
/// Create or retrieve the voiceBroadcast timeline coordinator for this event and return
/// a view to be displayed in the timeline
func buildVoiceBroadcastRecorderViewForEvent(_ event: MXEvent) -> UIView? {
guard let session = session, let room = session.room(withRoomId: event.roomId) else {
return nil
}
if let coordinator = coordinatorsForEventIdentifiers[event.eventId] {
return coordinator.toPresentable().view
}
let parameters = VoiceBroadcastRecorderCoordinatorParameters(session: session, room: room, voiceBroadcastStartEvent: event)
let coordinator = VoiceBroadcastRecorderCoordinator(parameters: parameters)
coordinatorsForEventIdentifiers[event.eventId] = coordinator
return coordinator.toPresentable().view
}
/// Retrieve the voiceBroadcast timeline coordinator for the given event or nil if it hasn't been created yet
func voiceBroadcastRecorderControllerForEventIdentifier(_ eventIdentifier: String) -> VoiceBroadcastRecorderCoordinator? {
coordinatorsForEventIdentifiers[eventIdentifier]
}
}
@@ -0,0 +1,199 @@
//
// Copyright 2022 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
class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
// MARK: - Properties
// MARK: Private
private let roomId: String
private let session: MXSession
private var voiceBroadcastService: VoiceBroadcastService? {
session.voiceBroadcastService
}
private let audioEngine = AVAudioEngine()
private var chunkFile: AVAudioFile! = nil
private var chunkFrames: AVAudioFrameCount = 0
private var chunkFileNumber: Int = 0
// MARK: Public
// MARK: - Setup
init(session: MXSession, roomId: String) {
self.session = session
self.roomId = roomId
}
// MARK: - VoiceBroadcastRecorderServiceProtocol
func startRecordingVoiceBroadcast() {
let inputNode = audioEngine.inputNode
let inputBus = AVAudioNodeBus(0)
let inputFormat = inputNode.inputFormat(forBus: inputBus)
MXLog.debug("[VoiceBroadcastRecorderService] Start recording voice broadcast for bus name : \(String(describing: inputNode.name(forInputBus: inputBus)))")
inputNode.installTap(onBus: inputBus,
bufferSize: 512,
format: inputFormat) { (buffer, time) -> Void in
DispatchQueue.main.async {
self.writeBuffer(buffer)
}
}
// FIXME: Update state
try? audioEngine.start()
}
func stopRecordingVoiceBroadcast() {
audioEngine.stop()
audioEngine.reset() // FIXME: Really needed ?
resetValues()
voiceBroadcastService?.stopVoiceBroadcast(success: { _ in
// update recording state
}, failure: { error in
MXLog.error("[VoiceBroadcastRecorderService] Failed to stop voice broadcast", context: error)
})
}
func pauseRecordingVoiceBroadcast() {
audioEngine.pause()
voiceBroadcastService?.pauseVoiceBroadcast(success: { _ in
// update recording state
}, failure: { error in
MXLog.error("[VoiceBroadcastRecorderService] Failed to pause voice broadcast", context: error)
})
}
func resumeRecordingVoiceBroadcast() {
try? audioEngine.start() // FIXME: Verifiy if start is ok for a restart/resume
voiceBroadcastService?.resumeVoiceBroadcast(success: { _ in
// update recording state
}, failure: { error in
MXLog.error("[VoiceBroadcastRecorderService] Failed to resume voice broadcast", context: error)
})
}
// MARK: - Private
/// Reset chunk values.
private func resetValues() {
chunkFrames = 0
chunkFileNumber = 0
}
/// Write audio buffer to chunk file.
private func writeBuffer(_ buffer: AVAudioPCMBuffer) {
let sampleRate = buffer.format.sampleRate
if chunkFile == nil {
createNewChunkFile(sampleRate: sampleRate)
}
try? chunkFile.write(from: buffer)
chunkFrames += buffer.frameLength
if chunkFrames > AVAudioFrameCount(Double(BuildSettings.voiceBroadcastChunkLength) * sampleRate) {
sendChunkFile(at: chunkFile.url)
// Reset chunkFile
chunkFile = nil
}
}
/// Create new chunk file with sample rate.
private func createNewChunkFile(sampleRate: Float64) {
guard let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
// FIXME: Manage error
return
}
let fileUrl = directory.appendingPathComponent("VoiceBroadcastChunk-\(roomId)-\(chunkFileNumber).m4a")
MXLog.debug("[VoiceBroadcastRecorderService] Create chunk file to \(fileUrl)")
let settings: [String: Any] = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: sampleRate,
AVEncoderBitRateKey: 128000,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
chunkFile = try! AVAudioFile(forWriting: fileUrl, settings: settings)
chunkFileNumber += 1
chunkFrames = 0
}
/// Send chunk file to the server.
private func sendChunkFile(at url: URL) {
guard let voiceBroadcastService = voiceBroadcastService else {
// FIXME: Manage error
return
}
let dispatchGroup = DispatchGroup()
var duration = 0.0
dispatchGroup.enter()
VoiceMessageAudioConverter.mediaDurationAt(url) { result in
switch result {
case .success:
if let someDuration = try? result.get() {
duration = someDuration
} else {
MXLog.error("[VoiceBroadcastRecorderService] Failed retrieving media duration")
}
case .failure(let error):
MXLog.error("[VoiceBroadcastRecorderService] Failed getting audio duration", context: error)
}
dispatchGroup.leave()
}
dispatchGroup.notify(queue: .main) {
voiceBroadcastService.sendChunkOfVoiceBroadcast(audioFileLocalURL: url,
mimeType: "audio/mp4",
duration: UInt(duration * 1000),
samples: nil) { eventId in
MXLog.debug("[VoiceBroadcastRecorderService] sendChunkOfVoiceBroadcast success.")
if eventId != nil {
self.deleteRecording(at: url)
}
} failure: { error in
MXLog.error("[VoiceBroadcastRecorderService] sendChunkOfVoiceBroadcast error.", context: error)
}
}
}
/// Delete voice broadcast chunk at URL.
private func deleteRecording(at url: URL?) {
guard let url = url else {
return
}
do {
try FileManager.default.removeItem(at: url)
} catch {
MXLog.error("[VoiceBroadcastRecorderService] deleteRecordingAtURL:", context: error)
}
}
}
@@ -0,0 +1,32 @@
//
// Copyright 2022 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
protocol VoiceBroadcastRecorderServiceProtocol {
/// Start voice broadcast recording.
func startRecordingVoiceBroadcast()
/// Stop voice broadcast recording.
func stopRecordingVoiceBroadcast()
/// Pause voice broadcast recording.
func pauseRecordingVoiceBroadcast()
/// Resume voice broadcast recording after paused it.
func resumeRecordingVoiceBroadcast()
}
@@ -0,0 +1,84 @@
//
// Copyright 2022 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 SwiftUI
struct VoiceBroadcastRecorderView: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: VoiceBroadcastRecorderViewModel.Context
var body: some View {
VStack(alignment: .leading, spacing: 16.0) {
Text(VectorL10n.voiceBroadcastInTimelineTitle)
.font(theme.fonts.bodySB)
.foregroundColor(theme.colors.primaryContent)
HStack(alignment: .top, spacing: 16.0) {
Button {
// FIXME: Manage record in progress case
viewModel.send(viewAction: .start)
} label: {
// FIXME: Manage record in progress case
Image("voice_broadcast_record")
.renderingMode(.original)
}
.accessibilityIdentifier("recordButton")
Button {
// FIXME: Manage resume case
viewModel.send(viewAction: .pause)
} label: {
Image("voice_broadcast_record_pause")
.renderingMode(.original)
}
.accessibilityIdentifier("pauseButton")
}
}
.padding([.horizontal, .top], 2.0)
.padding([.bottom])
}
// private func updateRecordingStatus() {
// switch viewModel.viewState.recordingState {
// case .started:
// viewModel.send(viewAction: .stop)
// case .paused:
// viewModel.send(viewAction: .resume)
// case .stopped:
// viewModel.send(viewAction: .start)
// case .resumed:
// viewModel.send(viewAction: .pause)
// }
// }
}
// MARK: - Previews
struct VoiceBroadcastRecorderView_Previews: PreviewProvider {
static let stateRenderer = MockVoiceBroadcastRecorderScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
}
@@ -0,0 +1,44 @@
//
// Copyright 2022 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 Foundation
enum VoiceBroadcastRecorderViewAction {
case start
case stop
case pause
case resume
}
enum VoiceBroadcastRecorderState {
case started
case stopped
case paused
case resumed
}
struct VoiceBroadcastRecorderViewState: BindableState {
var recordingState: VoiceBroadcastRecorderState
var bindings: VoiceBroadcastRecorderViewStateBindings
}
struct VoiceBroadcastRecorderViewStateBindings {
// var alertInfo: AlertInfo<VoiceBroadcastRecorderAlertType>?
}
enum VoiceBroadcastRecorderAlertType {
// case failedClosingVoiceBroadcast
}
@@ -0,0 +1,41 @@
//
// Copyright 2022 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 Foundation
import SwiftUI
typealias MockVoiceBroadcastRecorderViewModelType = StateStoreViewModel<VoiceBroadcastRecorderViewState, VoiceBroadcastRecorderViewAction>
class MockVoiceBroadcastRecorderViewModel: MockVoiceBroadcastRecorderViewModelType, VoiceBroadcastRecorderViewModelProtocol {
}
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockVoiceBroadcastRecorderScreenState: MockScreenState, CaseIterable {
var screenType: Any.Type {
VoiceBroadcastRecorderView.self
}
var screenView: ([Any], AnyView) {
let viewModel = MockVoiceBroadcastRecorderViewModel(initialViewState: VoiceBroadcastRecorderViewState(recordingState: .stopped, bindings: VoiceBroadcastRecorderViewStateBindings()))
return (
[false, viewModel],
AnyView(VoiceBroadcastRecorderView(viewModel: viewModel.context))
)
}
}
@@ -0,0 +1,74 @@
//
// Copyright 2022 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 SwiftUI
typealias VoiceBroadcastRecorderViewModelType = StateStoreViewModel<VoiceBroadcastRecorderViewState, VoiceBroadcastRecorderViewAction>
class VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelType, VoiceBroadcastRecorderViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let voiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol
// MARK: Public
// MARK: - Setup
init(recorderService: VoiceBroadcastRecorderServiceProtocol) {
self.voiceBroadcastRecorderService = recorderService
super.init(initialViewState: VoiceBroadcastRecorderViewState(recordingState: .stopped, bindings: VoiceBroadcastRecorderViewStateBindings()))
}
// MARK: - Public
override func process(viewAction: VoiceBroadcastRecorderViewAction) {
switch viewAction {
case .start:
start()
case .stop:
stop()
case .pause:
pause()
case .resume:
resume()
}
}
// MARK: - Private
private func start() {
self.state.recordingState = .started
voiceBroadcastRecorderService.startRecordingVoiceBroadcast()
}
private func stop() {
self.state.recordingState = .stopped
voiceBroadcastRecorderService.stopRecordingVoiceBroadcast()
}
private func pause() {
self.state.recordingState = .paused
voiceBroadcastRecorderService.pauseRecordingVoiceBroadcast()
}
private func resume() {
self.state.recordingState = .resumed
voiceBroadcastRecorderService.resumeRecordingVoiceBroadcast()
}
}
@@ -0,0 +1,21 @@
//
// Copyright 2022 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 Foundation
protocol VoiceBroadcastRecorderViewModelProtocol {
var context: VoiceBroadcastRecorderViewModelType.Context { get }
}