Allow to record a voice message (#1926)

This commit is contained in:
Nicolas Mauri
2023-10-23 17:47:36 +02:00
committed by GitHub
parent 65b7c1df20
commit 2f57fbc77d
62 changed files with 1805 additions and 342 deletions
@@ -30,7 +30,6 @@ enum AudioConverterPreferredFileExtension: String {
struct AudioConverter: AudioConverterProtocol {
func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws {
do {
MXLog.debug("converting \(sourceURL) to \(destinationURL)")
try OGGConverter.convertM4aFileToOpusOGG(src: sourceURL, dest: destinationURL)
} catch {
MXLog.error("failed to convert to OpusOgg: \(error)")
@@ -30,11 +30,6 @@ private enum InternalAudioPlayerState {
case error(Error)
}
enum AudioPlayerError: Error {
case genericError
case loadFileError
}
class AudioPlayer: NSObject, AudioPlayerProtocol {
var mediaSource: MediaSourceProxy?
@@ -53,6 +48,7 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
private var rateObserver: NSKeyValueObservation?
private var playToEndObserver: NSObjectProtocol?
private var appBackgroundObserver: NSObjectProtocol?
private var autoplay = false
private(set) var url: URL?
@@ -91,11 +87,12 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
unloadContent()
}
func load(mediaSource: MediaSourceProxy, using url: URL) {
func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool) {
unloadContent()
setInternalState(.loading)
self.mediaSource = mediaSource
self.url = url
self.autoplay = autoplay
playerItem = AVPlayerItem(url: url)
internalAudioPlayer = AVQueuePlayer(playerItem: playerItem)
addObservers()
@@ -122,6 +119,7 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
isStopped = true
internalAudioPlayer?.pause()
internalAudioPlayer?.seek(to: .zero)
try? AVAudioSession.sharedInstance().setActive(false)
}
func seek(to progress: Double) async {
@@ -205,7 +203,10 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
actionsSubject.send(.didStartLoading)
case .readyToPlay:
actionsSubject.send(.didFinishLoading)
play()
if autoplay {
autoplay = false
play()
}
case .playing:
actionsSubject.send(.didStartPlaying)
case .paused:
@@ -17,6 +17,11 @@
import Combine
import Foundation
enum AudioPlayerError: Error {
case genericError
case loadFileError
}
enum AudioPlayerAction {
case didStartLoading
case didFinishLoading
@@ -26,9 +26,14 @@ enum AudioPlayerPlaybackState {
case error
}
enum AudioPlayerStateIdentifier {
case timelineItemIdentifier(TimelineItemIdentifier)
case recorderPreview
}
@MainActor
class AudioPlayerState: ObservableObject, Identifiable {
let id = UUID()
let id: AudioPlayerStateIdentifier
let duration: Double
let waveform: EstimatedWaveform
@Published private(set) var playbackState: AudioPlayerPlaybackState
@@ -50,7 +55,8 @@ class AudioPlayerState: ObservableObject, Identifiable {
displayLink != nil
}
init(duration: Double, waveform: EstimatedWaveform? = nil, progress: Double = 0.0) {
init(id: AudioPlayerStateIdentifier, duration: Double, waveform: EstimatedWaveform? = nil, progress: Double = 0.0) {
self.id = id
self.duration = duration
self.waveform = waveform ?? EstimatedWaveform(data: [])
self.progress = progress
@@ -17,16 +17,14 @@
import AVFoundation
import Combine
import Foundation
enum AudioRecorderError: Error {
case genericError
}
import UIKit
class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
private let silenceThreshold: Float = -50.0
private var audioRecorder: AVAudioRecorder?
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<AudioRecorderAction, Never> = .init()
var actions: AnyPublisher<AudioRecorderAction, Never> {
actionsSubject.eraseToAnyPublisher()
@@ -44,7 +42,11 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
audioRecorder?.isRecording ?? false
}
func recordWithOutputURL(_ url: URL) {
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> {
guard await requestRecordPermission() else {
return .failure(.recordPermissionNotGranted)
}
let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 48000,
AVEncoderBitRateKey: 128_000,
@@ -54,6 +56,7 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
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
@@ -63,25 +66,26 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
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))
}
return .success(())
}
func stopRecording() async {
guard let audioRecorder, audioRecorder.isRecording else {
return
}
audioRecorder.stop()
}
func deleteRecording() {
audioRecorder?.deleteRecording()
}
func peakPowerForChannelNumber(_ channelNumber: Int) -> Float {
guard isRecording, let audioRecorder else {
return 0.0
}
audioRecorder.updateMeters()
return normalizedPowerLevelFromDecibels(audioRecorder.peakPower(forChannel: channelNumber))
}
@@ -91,13 +95,37 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
}
audioRecorder.updateMeters()
return normalizedPowerLevelFromDecibels(audioRecorder.averagePower(forChannel: channelNumber))
}
// 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
continuation.resume(returning: granted)
}
}
}
// MARK: - AVAudioRecorderDelegate
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) {
try? AVAudioSession.sharedInstance().setActive(false)
if success {
actionsSubject.send(.didStopRecording)
} else {
@@ -106,6 +134,7 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate {
}
func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
try? AVAudioSession.sharedInstance().setActive(false)
actionsSubject.send(.didFailWithError(error: error ?? AudioRecorderError.genericError))
}
@@ -17,6 +17,24 @@
import Combine
import Foundation
enum AudioRecordingIdentifier {
case uuid(UUID)
}
extension AudioRecordingIdentifier {
var identifier: String {
switch self {
case .uuid(let uuid):
return uuid.uuidString
}
}
}
enum AudioRecorderError: Error {
case genericError
case recordPermissionNotGranted
}
enum AudioRecorderAction {
case didStartRecording
case didStopRecording
@@ -29,8 +47,9 @@ protocol AudioRecorderProtocol: AnyObject {
var isRecording: Bool { get }
var url: URL? { get }
func recordWithOutputURL(_ url: URL)
func stopRecording()
func record(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError>
func stopRecording() async
func deleteRecording()
func averagePowerForChannelNumber(_ channelNumber: Int) -> Float
}
@@ -30,25 +30,27 @@ class AudioRecorderState: ObservableObject, Identifiable {
@Published private(set) var recordingState: AudioRecorderRecordingState = .stopped
@Published private(set) var duration = 0.0
@Published private(set) var waveform = EstimatedWaveform(data: Array(repeating: 0, count: 100))
@Published private(set) var waveformSamples: [Float] = []
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)
if audioRecorder.isRecording {
startPublishUpdates()
}
}
func detachAudioRecorder() {
guard audioRecorder != nil else { return }
audioRecorder?.stopRecording()
func detachAudioRecorder() async {
if let audioRecorder, audioRecorder.isRecording {
await audioRecorder.stopRecording()
}
stopPublishUpdates()
waveformSamples = []
cancellables = []
audioRecorder = nil
recordingState = .stopped
@@ -91,7 +93,7 @@ class AudioRecorderState: ObservableObject, Identifiable {
stopPublishUpdates()
}
displayLink = CADisplayLink(target: self, selector: #selector(publishUpdate))
displayLink?.preferredFrameRateRange = .init(minimum: 10, maximum: 20)
displayLink?.preferredFrameRateRange = .init(minimum: 30, maximum: 60)
displayLink?.add(to: .current, forMode: .common)
}
@@ -99,6 +101,9 @@ class AudioRecorderState: ObservableObject, Identifiable {
if let currentTime = audioRecorder?.currentTime {
duration = currentTime
}
if let sample = audioRecorder?.averagePowerForChannelNumber(0) {
waveformSamples.append(sample)
}
}
private func stopPublishUpdates() {
@@ -31,7 +31,7 @@ protocol MediaPlayerProtocol: AnyObject {
var url: URL? { get }
var state: MediaPlayerState { get }
func load(mediaSource: MediaSourceProxy, using url: URL)
func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool)
func play()
func pause()
func stop()
@@ -17,31 +17,72 @@
import Foundation
class MediaPlayerProvider: MediaPlayerProviderProtocol {
private let mediaProvider: MediaProviderProtocol
private var audioPlayer: AudioPlayerProtocol?
init(mediaProvider: MediaProviderProtocol) {
self.mediaProvider = mediaProvider
}
private lazy var audioPlayer = AudioPlayer()
private var audioPlayerStates: [String: AudioPlayerState] = [:]
deinit {
audioPlayer = nil
audioPlayerStates = [:]
}
func player(for mediaSource: MediaSourceProxy) -> MediaPlayerProtocol? {
func player(for mediaSource: MediaSourceProxy) -> Result<MediaPlayerProtocol, MediaPlayerProviderError> {
guard let mimeType = mediaSource.mimeType else {
MXLog.error("Unknown mime type")
return nil
return .failure(.unsupportedMediaType)
}
if mimeType.starts(with: "audio/") {
if audioPlayer == nil {
audioPlayer = AudioPlayer()
}
return audioPlayer
return .success(audioPlayer)
} else {
MXLog.error("Unsupported media type: \(mediaSource.mimeType ?? "unknown")")
return .failure(.unsupportedMediaType)
}
}
// MARK: - AudioPlayer
func playerState(for id: AudioPlayerStateIdentifier) -> AudioPlayerState? {
guard let audioPlayerStateID = audioPlayerStateID(for: id) else {
MXLog.error("Failed to build an ID using: \(id)")
return nil
}
return audioPlayerStates[audioPlayerStateID]
}
@MainActor
func register(audioPlayerState: AudioPlayerState) {
guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else {
MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)")
return
}
audioPlayerStates[audioPlayerStateID] = audioPlayerState
}
@MainActor
func unregister(audioPlayerState: AudioPlayerState) {
guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else {
MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)")
return
}
audioPlayerStates[audioPlayerStateID] = nil
}
func detachAllStates(except exception: AudioPlayerState?) async {
for key in audioPlayerStates.keys {
if let exception, key == audioPlayerStateID(for: exception.id) {
continue
}
await audioPlayerStates[key]?.detachAudioPlayer()
}
}
// MARK: - Private
private func audioPlayerStateID(for identifier: AudioPlayerStateIdentifier) -> String? {
switch identifier {
case .timelineItemIdentifier(let timelineItemIdentifier):
return timelineItemIdentifier.eventID
case .recorderPreview:
return "recorderPreviewAudioPlayerState"
}
}
}
@@ -16,6 +16,18 @@
import Foundation
protocol MediaPlayerProviderProtocol {
func player(for mediaSource: MediaSourceProxy) async -> MediaPlayerProtocol?
enum MediaPlayerProviderError: Error {
case unsupportedMediaType
}
protocol MediaPlayerProviderProtocol {
func player(for mediaSource: MediaSourceProxy) -> Result<MediaPlayerProtocol, MediaPlayerProviderError>
func playerState(for id: AudioPlayerStateIdentifier) -> AudioPlayerState?
func register(audioPlayerState: AudioPlayerState)
func unregister(audioPlayerState: AudioPlayerState)
func detachAllStates(except exception: AudioPlayerState?) async
}
// sourcery: AutoMockable
extension MediaPlayerProviderProtocol { }
@@ -375,6 +375,31 @@ class RoomProxy: RoomProxyProtocol {
return .success(())
}
func sendVoiceMessage(url: URL,
audioInfo: AudioInfo,
waveform: [UInt16],
progressSubject: CurrentValueSubject<Double, Never>?,
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, RoomProxyError> {
sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true)
defer {
sendMessageBackgroundTask?.stop()
}
let handle = room.sendVoiceMessage(url: url.path(percentEncoded: false), audioInfo: audioInfo, waveform: waveform, progressWatcher: UploadProgressListener { progress in
progressSubject?.send(progress)
})
await requestHandle(handle)
do {
try await handle.join()
} catch {
return .failure(.failedSendingMedia)
}
return .success(())
}
func sendFile(url: URL,
fileInfo: FileInfo,
progressSubject: CurrentValueSubject<Double, Never>?,
@@ -131,6 +131,12 @@ protocol RoomProxyProtocol {
zoomLevel: UInt8?,
assetType: AssetType?) async -> Result<Void, RoomProxyError>
func sendVoiceMessage(url: URL,
audioInfo: AudioInfo,
waveform: [UInt16],
progressSubject: CurrentValueSubject<Double, Never>?,
requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result<Void, RoomProxyError>
/// Retries sending a failed message given its transaction ID
func retrySend(transactionID: String) async
@@ -89,7 +89,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func retryDecryption(for sessionID: String) async { }
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState {
AudioPlayerState(duration: 10.0,
AudioPlayerState(id: .timelineItemIdentifier(itemID),
duration: 10.0,
waveform: nil,
progress: 0.0)
}
@@ -38,7 +38,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
private(set) var timelineItems = [RoomTimelineItemProtocol]()
private var timelineAudioPlayerStates = [TimelineItemIdentifier: AudioPlayerState]()
var roomID: String {
roomProxy.id
@@ -249,13 +248,14 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
fatalError("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead")
}
if let playerState = timelineAudioPlayerStates[itemID] {
if let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) {
return playerState
}
let playerState = AudioPlayerState(duration: voiceMessageRoomTimelineItem.content.duration,
let playerState = AudioPlayerState(id: .timelineItemIdentifier(itemID),
duration: voiceMessageRoomTimelineItem.content.duration,
waveform: voiceMessageRoomTimelineItem.content.waveform)
timelineAudioPlayerStates[itemID] = playerState
mediaPlayerProvider.register(audioPlayerState: playerState)
return playerState
}
@@ -273,52 +273,48 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
return
}
guard let player = await mediaPlayerProvider.player(for: source) as? AudioPlayerProtocol else {
guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: source), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
MXLog.error("Cannot play a voice message without an audio player")
return
}
let playerState = audioPlayerState(for: itemID)
let audioPlayerState = audioPlayerState(for: itemID)
guard player.mediaSource == source, player.state != .error else {
timelineAudioPlayerStates.forEach { itemID, playerState in
if itemID != timelineItem.id {
playerState.detachAudioPlayer()
}
}
playerState.attachAudioPlayer(player)
guard audioPlayer.mediaSource == source, audioPlayer.state != .error else {
audioPlayer.stop()
await mediaPlayerProvider.detachAllStates(except: audioPlayerState)
audioPlayerState.attachAudioPlayer(audioPlayer)
// Load content
do {
let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(source, body: nil)
// Make sure that the player is still attached, as it may have been detached while waiting for the voice message to be loaded.
if playerState.isAttached {
player.load(mediaSource: source, using: url)
if audioPlayerState.isAttached {
audioPlayer.load(mediaSource: source, using: url, autoplay: true)
}
} catch {
MXLog.error("Failed to load voice message: \(error)")
playerState.reportError(error)
audioPlayerState.reportError(error)
}
return
}
if player.state == .playing {
player.pause()
if audioPlayer.state == .playing {
audioPlayer.pause()
} else {
player.play()
audioPlayer.play()
}
}
func pauseAudio() {
timelineAudioPlayerStates.forEach { _, playerState in
playerState.detachAudioPlayer()
}
}
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async {
await timelineAudioPlayerStates[itemID]?.updateState(progress: progress)
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else {
return
}
await playerState.updateState(progress: progress)
}
// MARK: - Private
@@ -402,11 +398,11 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
// Stops the audio player when a voice message is redacted.
if timelineItem is RedactedRoomTimelineItem {
guard let audioState = timelineAudioPlayerStates[timelineItem.id] else {
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else {
continue
}
audioState.detachAudioPlayer()
timelineAudioPlayerStates.removeValue(forKey: timelineItem.id)
playerState.detachAudioPlayer()
mediaPlayerProvider.unregister(audioPlayerState: playerState)
}
newTimelineItems.append(timelineItem)
@@ -74,9 +74,7 @@ protocol RoomTimelineControllerProtocol {
func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState
func playPauseAudio(for itemID: TimelineItemIdentifier) async
func pauseAudio()
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async
}
@@ -1,132 +0,0 @@
//
// 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 SwiftUI
struct EstimatedWaveform: Equatable, Hashable {
static let dataRange: ClosedRange<UInt16> = 0...1024
let data: [UInt16]
}
extension EstimatedWaveform {
/// Maps the `data` array to Float values in the range 0...1 respecting the `Self.dataRange` limits.
/// Up to `maxSamplesCount` will be returned in the output array
func normalisedData(maxSamplesCount: Int) -> [Float] {
guard maxSamplesCount > 0 else {
return []
}
// Filter the data to keep only the expected number of samples
let result: [UInt16]
if data.count > maxSamplesCount {
result = (0..<maxSamplesCount)
.map { index in
let targetIndex = Int((Double(index) * (Double(data.count) / Double(maxSamplesCount))).rounded())
return UInt16(data[targetIndex])
}
} else {
result = data
}
// Normalize the sample in the allowed range
return result.map { Float($0) / Float(Self.dataRange.upperBound) }
}
}
extension EstimatedWaveform {
static let mockWaveform = EstimatedWaveform(data: [0, 0, 0, 3, 3, 127, 400, 266, 126, 122, 373, 251, 45, 112,
334, 205, 99, 138, 397, 354, 125, 361, 199, 51,
294, 131, 19, 2, 3, 3, 1, 2, 0, 0,
0, 0])
}
struct EstimatedWaveformView: View {
var lineWidth: CGFloat = 2
var linePadding: CGFloat = 2
var waveform: EstimatedWaveform
private let minimumGraphAmplitude: CGFloat = 1
var progress: CGFloat = 0.0
@State private var normalizedWaveformData: [Float] = []
var body: some View {
GeometryReader { geometry in
WaveformShape(lineWidth: lineWidth,
linePadding: linePadding,
waveformData: normalizedWaveformData)
.stroke(Color.compound.iconSecondary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.progressMask(progress: progress)
.preference(key: ViewSizeKey.self, value: geometry.size)
}
.onPreferenceChange(ViewSizeKey.self) { size in
buildNormalizedWaveformData(size: size)
}
}
private func buildNormalizedWaveformData(size: CGSize) {
let count = Int(size.width / (lineWidth + linePadding))
// Rebuild the normalized waveform data only if the count has changed
if normalizedWaveformData.count == count {
return
}
normalizedWaveformData = waveform.normalisedData(maxSamplesCount: count)
}
}
private struct ViewSizeKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
private struct WaveformShape: Shape {
let lineWidth: CGFloat
let linePadding: CGFloat
let waveformData: [Float]
var minimumGraphAmplitude: CGFloat = 1.0
func path(in rect: CGRect) -> Path {
let width = rect.size.width
let height = rect.size.height
let centerY = rect.size.height / 2
var xOffset: CGFloat = lineWidth / 2
var index = 0
var path = Path()
while xOffset <= width {
let sample = CGFloat(index >= waveformData.count ? 0 : waveformData[index])
let drawingAmplitude = max(minimumGraphAmplitude, sample * (height - 2))
path.move(to: CGPoint(x: xOffset, y: centerY - drawingAmplitude / 2))
path.addLine(to: CGPoint(x: xOffset, y: centerY + drawingAmplitude / 2))
xOffset += lineWidth + linePadding
index += 1
}
return path
}
}
struct EstimatedWaveformView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
// Wrap the WaveformView in a VStack otherwise the preview test will fail (because of Prefire / GeometryReader)
VStack {
EstimatedWaveformView(waveform: EstimatedWaveform.mockWaveform, progress: 0.5)
.frame(width: 140, height: 50)
}
}
}
@@ -1,56 +0,0 @@
//
// 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 SwiftUI
extension View {
func progressMask(progress: CGFloat,
trackColor: Color = .compound.iconSecondary,
backgroundTrackColor: Color = .compound.iconQuaternary) -> some View {
modifier(ProgressMaskModifier(progress: progress,
trackColor: trackColor,
backgroundTrackColor: backgroundTrackColor))
}
}
private struct ProgressMaskModifier: ViewModifier {
private let progress: CGFloat
private let trackColor: Color
private let backgroundTrackColor: Color
init(progress: CGFloat, trackColor: Color, backgroundTrackColor: Color) {
self.progress = progress
self.trackColor = trackColor
self.backgroundTrackColor = backgroundTrackColor
}
func body(content: Content) -> some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.fill(backgroundTrackColor)
.frame(width: geometry.size.width, height: geometry.size.height)
Rectangle()
.fill(trackColor)
.frame(width: max(0.0, geometry.size.width * progress), height: geometry.size.height)
}
.mask {
content
}
}
}
}
@@ -45,8 +45,7 @@ struct VoiceMessageRoomPlaybackView: View {
return dateFormatter
}()
@GestureState private var dragState = DragState.inactive
@State private var tapProgress: Double = .zero
@State var dragState: WaveformViewDragState = .inactive
var timeLabelContent: String {
// Display the duration if progress is 0.0
@@ -85,38 +84,14 @@ struct VoiceMessageRoomPlaybackView: View {
.frame(width: waveformLineWidth, height: geometry.size.height)
.opacity(showWaveformCursor ? 1 : 0)
}
// Add a gesture to drag the waveform
.gesture(SpatialTapGesture()
.simultaneously(with: LongPressGesture())
.sequenced(before: DragGesture(minimumDistance: waveformLinePadding, coordinateSpace: .local))
.updating($dragState) { value, state, _ in
switch value {
// (SpatialTap, LongPress) begins.
case .first(let spatialLongPress) where spatialLongPress.second ?? false:
// Compute the progress with the spatialTap location
let progress = (spatialLongPress.first?.location ?? .zero).x / geometry.size.width
state = .pressing(progress: progress)
// Long press confirmed, dragging may begin.
case .second(let spatialLongPress, let drag) where spatialLongPress.second ?? false:
var progress: Double = tapProgress
// Compute the progress with drag location
if let loc = drag?.location {
progress = loc.x / geometry.size.width
}
state = .dragging(progress: progress)
// Dragging ended or the long press cancelled.
default:
state = .inactive
}
})
.waveformDragGesture($dragState)
}
}
.onChange(of: dragState) { newDragState in
switch newDragState {
case .inactive:
onScrubbing(false)
case .pressing(let progress):
tapProgress = progress
case .pressing:
onScrubbing(true)
feedbackGenerator.prepare()
sendFeedback = true
@@ -218,7 +193,8 @@ struct VoiceMessageRoomPlaybackView_Previews: PreviewProvider, TestablePreview {
294, 131, 19, 2, 3, 3, 1, 2, 0, 0,
0, 0, 0, 0, 0, 3])
static var playerState = AudioPlayerState(duration: 10.0,
static var playerState = AudioPlayerState(id: .timelineItemIdentifier(.random),
duration: 10.0,
waveform: waveform,
progress: 0.3)
@@ -65,8 +65,8 @@ struct VoiceMessageRoomTimelineView: View {
struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: .random,
static let timelineItemIdentifier = TimelineItemIdentifier.random
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: timelineItemIdentifier,
timestamp: "Now",
isOutgoing: false,
isEditable: false,
@@ -79,7 +79,8 @@ struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
source: nil,
contentType: nil))
static let playerState = AudioPlayerState(duration: 10.0,
static let playerState = AudioPlayerState(id: .timelineItemIdentifier(timelineItemIdentifier),
duration: 10.0,
waveform: EstimatedWaveform.mockWaveform,
progress: 0.4)
@@ -75,7 +75,7 @@ struct RoomTimelineItemView: View {
case .poll(let item):
PollRoomTimelineView(timelineItem: item)
case .voice(let item):
VoiceMessageRoomTimelineView(timelineItem: item, playerState: context.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(duration: 0))
VoiceMessageRoomTimelineView(timelineItem: item, playerState: context.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(id: .timelineItemIdentifier(item.id), duration: 0))
}
}
@@ -16,36 +16,31 @@
import Foundation
enum VoiceMessageCacheError: Error {
case invalidFileExtension
}
class VoiceMessageCache: VoiceMessageCacheProtocol {
private let preferredFileExtension = "m4a"
private var temporaryFilesFolderURL: URL {
FileManager.default.temporaryDirectory.appendingPathComponent("media/voice-message")
}
func fileURL(for mediaSource: MediaSourceProxy) -> URL? {
let url = cacheURL(for: mediaSource)
return FileManager.default.fileExists(atPath: url.path()) ? url : nil
}
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool = false) throws -> URL {
guard fileURL.pathExtension == preferredFileExtension else {
throw VoiceMessageCacheError.invalidFileExtension
}
setupTemporaryFilesFolder()
let url = cacheURL(for: mediaSource)
try? FileManager.default.removeItem(at: url)
if move {
try FileManager.default.moveItem(at: fileURL, to: url)
} else {
try FileManager.default.copyItem(at: fileURL, to: url)
}
return url
}
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool = false) -> Result<URL, VoiceMessageCacheError> {
guard fileURL.pathExtension == preferredFileExtension else {
return .failure(.invalidFileExtension)
}
let url = cacheURL(for: mediaSource)
do {
try cacheFile(source: fileURL, destination: url, move: move)
} catch {
MXLog.error("Failed storing file in cache", context: error)
return .failure(.failedStoringFileInCache)
}
return .success(url)
}
func clearCache() {
if FileManager.default.fileExists(atPath: temporaryFilesFolderURL.path) {
do {
@@ -66,6 +61,16 @@ class VoiceMessageCache: VoiceMessageCacheProtocol {
}
}
private func cacheFile(source: URL, destination: URL, move: Bool) throws {
setupTemporaryFilesFolder()
try? FileManager.default.removeItem(at: destination)
if move {
try FileManager.default.moveItem(at: source, to: destination)
} else {
try FileManager.default.copyItem(at: source, to: destination)
}
}
private func cacheURL(for mediaSource: MediaSourceProxy) -> URL {
temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent).appendingPathExtension(preferredFileExtension)
}
@@ -16,9 +16,26 @@
import Foundation
enum VoiceMessageCacheError: Error {
case invalidFileExtension
case failedStoringFileInCache
}
protocol VoiceMessageCacheProtocol {
/// Returns the URL of the cached audio file for a given media source
/// - Parameter mediaSource: the media source
/// - Returns: the URL of the cached audio file or nil if the file doesn't exist
func fileURL(for mediaSource: MediaSourceProxy) -> URL?
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) throws -> URL
/// Adds a file in the cache
/// - Parameters:
/// - mediaSource: the media source
/// - fileURL: the source file
/// - move: wheter to move or copy the source file
/// - Returns: the cached URL
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) -> Result<URL, VoiceMessageCacheError>
/// Clears the cache
func clearCache()
}
@@ -80,7 +80,13 @@ class VoiceMessageMediaManager: VoiceMessageMediaManagerProtocol {
try audioConverter.convertToMPEG4AAC(sourceURL: fileHandle.url, destinationURL: convertedFileURL)
// Cache the file and return the url
return try voiceMessageCache.cache(mediaSource: source, using: convertedFileURL, move: true)
let result = voiceMessageCache.cache(mediaSource: source, using: convertedFileURL, move: true)
switch result {
case .success(let url):
return url
case .failure(let error):
throw error
}
}
}
@@ -0,0 +1,198 @@
//
// 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 DSWaveformImage
import Foundation
import MatrixRustSDK
class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
let audioRecorder: AudioRecorderProtocol
private let audioConverter: AudioConverterProtocol
private let voiceMessageCache: VoiceMessageCacheProtocol
private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let mp4accMimeType = "audio/m4a"
private let waveformSamplesCount = 100
private(set) var recordingURL: URL?
private(set) var recordingDuration: TimeInterval = 0.0
private(set) var previewAudioPlayerState: AudioPlayerState?
private(set) var previewAudioPlayer: AudioPlayerProtocol?
init(audioRecorder: AudioRecorderProtocol = AudioRecorder(),
mediaPlayerProvider: MediaPlayerProviderProtocol,
audioConverter: AudioConverterProtocol = AudioConverter(),
voiceMessageCache: VoiceMessageCacheProtocol = VoiceMessageCache()) {
self.audioRecorder = audioRecorder
self.mediaPlayerProvider = mediaPlayerProvider
self.audioConverter = audioConverter
self.voiceMessageCache = voiceMessageCache
}
// MARK: - Recording
func startRecording() async -> Result<Void, VoiceMessageRecorderError> {
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(())
}
}
func stopRecording() async -> Result<Void, VoiceMessageRecorderError> {
recordingDuration = audioRecorder.currentTime
await audioRecorder.stopRecording()
guard case .success = await finalizeRecording() else {
return .failure(.previewNotAvailable)
}
return .success(())
}
func cancelRecording() async {
await audioRecorder.stopRecording()
audioRecorder.deleteRecording()
recordingURL = nil
previewAudioPlayerState = nil
}
func deleteRecording() async {
await stopPlayback()
audioRecorder.deleteRecording()
previewAudioPlayerState = nil
recordingURL = nil
}
// MARK: - Preview
func startPlayback() async -> Result<Void, VoiceMessageRecorderError> {
guard let previewAudioPlayerState, let url = recordingURL else {
return .failure(.previewNotAvailable)
}
guard let audioPlayer = previewAudioPlayer else {
return .failure(.previewNotAvailable)
}
if audioPlayer.url == url {
audioPlayer.play()
return .success(())
}
await previewAudioPlayerState.attachAudioPlayer(audioPlayer)
let pendingMediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType)
audioPlayer.load(mediaSource: pendingMediaSource, using: url, autoplay: true)
return .success(())
}
func pausePlayback() {
previewAudioPlayer?.pause()
}
func stopPlayback() async {
guard let previewAudioPlayerState else {
return
}
await previewAudioPlayerState.detachAudioPlayer()
previewAudioPlayer?.stop()
}
func seekPlayback(to progress: Double) async {
await previewAudioPlayerState?.updateState(progress: progress)
}
func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> {
guard let url = recordingURL else {
return .failure(.missingRecordingFile)
}
// build the waveform
var waveformData: [UInt16] = []
let analyzer = WaveformAnalyzer()
do {
let samples = try await analyzer.samples(fromAudioAt: url, count: 100)
// linearly normalized to [0, 1] (1 -> -50 dB)
waveformData = samples.map { UInt16(max(0, (1 - $0) * 1024)) }
} catch {
MXLog.error("Waveform analysis failed: \(error)")
}
return .success(waveformData)
}
func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError> {
guard let url = recordingURL else {
return .failure(VoiceMessageRecorderError.missingRecordingFile)
}
// convert the file
let sourceFilename = url.deletingPathExtension().lastPathComponent
let oggFile = URL.temporaryDirectory.appendingPathComponent(sourceFilename).appendingPathExtension("ogg")
do {
try audioConverter.convertToOpusOgg(sourceURL: url, destinationURL: oggFile)
} catch {
return .failure(.failedSendingVoiceMessage)
}
// send it
let size: UInt64
do {
size = try UInt64(FileManager.default.sizeForItem(at: oggFile))
} catch {
MXLog.error("Failed to get the recording file size", context: error)
return .failure(.failedSendingVoiceMessage)
}
let audioInfo = AudioInfo(duration: recordingDuration, size: size, mimetype: "audio/ogg")
guard case .success(let waveform) = await buildRecordingWaveform() else {
return .failure(.failedSendingVoiceMessage)
}
let result = await roomProxy.sendVoiceMessage(url: oggFile,
audioInfo: audioInfo,
waveform: waveform,
progressSubject: nil) { _ in }
// delete the temporary file
try? FileManager.default.removeItem(at: oggFile)
if case .failure(let error) = result {
MXLog.error("Failed to send the voice message.", context: error)
return .failure(.failedSendingVoiceMessage)
}
return .success(())
}
// MARK: - Private
private func finalizeRecording() async -> Result<Void, VoiceMessageRecorderError> {
guard let url = recordingURL else {
return .failure(.previewNotAvailable)
}
// Build the preview audio player state
previewAudioPlayerState = await AudioPlayerState(id: .recorderPreview, duration: recordingDuration, waveform: EstimatedWaveform(data: []))
// Build the preview audio player
let mediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType)
guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: mediaSource), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else {
return .failure(.previewNotAvailable)
}
previewAudioPlayer = audioPlayer
return .success(())
}
}
@@ -0,0 +1,47 @@
//
// 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 Foundation
enum VoiceMessageRecorderError: Error {
case genericError
case missingRecordingFile
case previewNotAvailable
case audioRecorderError(AudioRecorderError)
case failedSendingVoiceMessage
}
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>
func cancelRecording() async
func startPlayback() async -> Result<Void, VoiceMessageRecorderError>
func pausePlayback()
func stopPlayback() async
func seekPlayback(to progress: Double) async
func deleteRecording() async
func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError>
func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError>
}
// sourcery: AutoMockable
extension VoiceMessageRecorderProtocol { }