186 lines
6.1 KiB
Swift
186 lines
6.1 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import AVFoundation
|
|
import CoreServices
|
|
import SignalMessaging
|
|
|
|
@objc
|
|
public class VoiceMessageModel: NSObject {
|
|
public let threadUniqueId: String
|
|
|
|
private static var draftVoiceMessageDirectory: URL { VoiceMessageModels.draftVoiceMessageDirectory }
|
|
|
|
@objc
|
|
public init(thread: TSThread) {
|
|
self.threadUniqueId = thread.uniqueId
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private static let audioExtension = "m4a"
|
|
private static let audioUTI: String = kUTTypeMPEG4Audio as String
|
|
|
|
@objc
|
|
public func prepareForSending() throws -> SignalAttachment {
|
|
guard !isRecording else {
|
|
throw OWSAssertionError("Can't send while actively recording")
|
|
}
|
|
|
|
guard OWSFileSystem.fileOrFolderExists(url: audioFile) else {
|
|
throw OWSAssertionError("Missing audio file")
|
|
}
|
|
|
|
let temporaryDirectory = URL(fileURLWithPath: OWSTemporaryDirectory(), isDirectory: true)
|
|
let temporaryAudioFile = URL(fileURLWithPath: audioFile.lastPathComponent, relativeTo: temporaryDirectory)
|
|
try FileManager.default.copyItem(at: audioFile, to: temporaryAudioFile)
|
|
|
|
let dataSource = try DataSourcePath.dataSource(with: temporaryAudioFile, shouldDeleteOnDeallocation: true)
|
|
dataSource.sourceFilename = outputFileName(at: Date())
|
|
|
|
let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: Self.audioUTI)
|
|
guard !attachment.hasError else {
|
|
throw OWSAssertionError("Failed to create voice message attachment: \(attachment.errorName ?? "Unknown Error")")
|
|
}
|
|
|
|
return attachment
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
public func saveDraft(transaction: SDSAnyWriteTransaction) {
|
|
VoiceMessageModels.saveDraft(threadUniqueId: threadUniqueId, transaction: transaction)
|
|
}
|
|
|
|
@objc
|
|
public func clearDraft(transaction: SDSAnyWriteTransaction) {
|
|
VoiceMessageModels.clearDraft(for: threadUniqueId, transaction: transaction)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private var directory: URL {
|
|
let directory = VoiceMessageModels.directory(for: threadUniqueId)
|
|
OWSFileSystem.ensureDirectoryExists(directory.path)
|
|
return directory
|
|
}
|
|
|
|
public lazy var audioWaveform: AudioWaveform? =
|
|
AudioWaveformManager.audioWaveform(forAudioPath: audioFile.path, waveformPath: waveformFile.path)
|
|
|
|
public lazy var audioPlayer: OWSAudioPlayer =
|
|
.init(mediaUrl: audioFile, audioBehavior: .audioMessagePlayback)
|
|
|
|
private var audioFile: URL { URL(fileURLWithPath: "voice-memo.\(Self.audioExtension)", relativeTo: directory) }
|
|
private var waveformFile: URL { URL(fileURLWithPath: "waveform.dat", relativeTo: directory) }
|
|
private func outputFileName(at date: Date) -> String {
|
|
String(
|
|
format: "%@ %@.%@",
|
|
OWSLocalizedString("VOICE_MESSAGE_FILE_NAME", comment: "Filename for voice messages."),
|
|
DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short),
|
|
Self.audioExtension
|
|
)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
public var isRecording: Bool { audioRecorder?.isRecording ?? false }
|
|
|
|
public lazy var duration: TimeInterval? = {
|
|
guard OWSFileSystem.fileOrFolderExists(url: audioFile) else { return nil }
|
|
audioPlayer.setupAudioPlayer()
|
|
return audioPlayer.duration
|
|
}()
|
|
|
|
private var audioRecorder: AVAudioRecorder? {
|
|
didSet {
|
|
guard oldValue != audioRecorder else { return }
|
|
if let oldValue = oldValue {
|
|
DeviceSleepManager.shared.removeBlock(blockObject: oldValue)
|
|
}
|
|
if let audioRecorder = audioRecorder {
|
|
DeviceSleepManager.shared.addBlock(blockObject: audioRecorder)
|
|
}
|
|
}
|
|
}
|
|
|
|
private lazy var audioActivity = AudioActivity(audioDescription: "Voice Message Recording", behavior: .playAndRecord)
|
|
|
|
public func startRecording() throws {
|
|
AssertIsOnMainThread()
|
|
|
|
guard !isRecording else {
|
|
throw OWSAssertionError("Attempted to start recording while recording is in progress")
|
|
}
|
|
|
|
OWSFileSystem.deleteContents(ofDirectory: directory.path)
|
|
|
|
guard audioSession.startAudioActivity(audioActivity) else {
|
|
throw OWSAssertionError("Couldn't configure audio session")
|
|
}
|
|
|
|
let audioRecorder: AVAudioRecorder
|
|
do {
|
|
audioRecorder = try AVAudioRecorder(
|
|
url: audioFile,
|
|
settings: [
|
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
AVSampleRateKey: 44100,
|
|
AVNumberOfChannelsKey: 2,
|
|
AVEncoderBitRateKey: 128 * 1024
|
|
]
|
|
)
|
|
self.audioRecorder = audioRecorder
|
|
} catch {
|
|
throw OWSAssertionError("Couldn't create audioRecorder: \(error)")
|
|
}
|
|
|
|
audioRecorder.isMeteringEnabled = true
|
|
|
|
guard audioRecorder.prepareToRecord() else {
|
|
throw OWSAssertionError("audioRecorder couldn't prepareToRecord.")
|
|
}
|
|
|
|
guard audioRecorder.record() else {
|
|
throw OWSAssertionError("audioRecorder couldn't record.")
|
|
}
|
|
}
|
|
|
|
public func stopRecording() {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let audioRecorder = audioRecorder else { return }
|
|
|
|
self.duration = audioRecorder.currentTime
|
|
|
|
audioRecorder.stop()
|
|
|
|
self.audioRecorder = nil
|
|
|
|
// This is expensive. We can safely do it in the background.
|
|
DispatchQueue.sharedUserInteractive.async {
|
|
self.audioSession.endAudioActivity(self.audioActivity)
|
|
}
|
|
}
|
|
|
|
public func stopRecordingAsync() {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let audioRecorder = audioRecorder else { return }
|
|
|
|
self.audioRecorder = nil
|
|
self.duration = audioRecorder.currentTime
|
|
|
|
// This is expensive. We can safely do it in the background
|
|
// if we're not relying on the recorded audio (e.g. we canceled)
|
|
DispatchQueue.sharedUserInteractive.async {
|
|
audioRecorder.stop()
|
|
self.audioSession.endAudioActivity(self.audioActivity)
|
|
}
|
|
}
|
|
}
|