148 lines
4.7 KiB
Swift
148 lines
4.7 KiB
Swift
import AVFoundation
|
|
import Combine
|
|
import Foundation
|
|
|
|
@MainActor
|
|
final class AudioMessageRecorder: NSObject, ObservableObject {
|
|
struct Recording {
|
|
let url: URL
|
|
let duration: TimeInterval
|
|
}
|
|
|
|
@Published private(set) var isRecording = false
|
|
@Published private(set) var elapsed: TimeInterval = 0
|
|
|
|
private var recorder: AVAudioRecorder?
|
|
private var timer: Timer?
|
|
private var startedAt: Date?
|
|
|
|
func start() async throws {
|
|
guard !isRecording else { return }
|
|
guard await requestPermission() else { throw AudioRecorderError.permissionDenied }
|
|
guard !Task.isCancelled else { throw CancellationError() }
|
|
var didStart = false
|
|
defer {
|
|
#if os(iOS)
|
|
if !didStart {
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if os(iOS)
|
|
let session = AVAudioSession.sharedInstance()
|
|
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP])
|
|
try session.setActive(true)
|
|
#endif
|
|
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("LumaRecordings", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
let url = directory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
|
|
let settings: [String: Any] = [
|
|
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
|
AVSampleRateKey: 44_100.0,
|
|
AVNumberOfChannelsKey: 1,
|
|
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
|
]
|
|
let recorder = try AVAudioRecorder(url: url, settings: settings)
|
|
recorder.prepareToRecord()
|
|
guard recorder.record() else { throw AudioRecorderError.cannotStart }
|
|
|
|
self.recorder = recorder
|
|
startedAt = Date()
|
|
elapsed = 0
|
|
isRecording = true
|
|
didStart = true
|
|
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
Task { @MainActor in
|
|
guard let self, let startedAt = self.startedAt else { return }
|
|
self.elapsed = Date().timeIntervalSince(startedAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func finish() throws -> Recording {
|
|
guard let recorder, isRecording else { throw AudioRecorderError.notRecording }
|
|
let duration = max(recorder.currentTime, elapsed)
|
|
let url = recorder.url
|
|
recorder.stop()
|
|
stopState()
|
|
guard duration >= 0.4 else {
|
|
try? FileManager.default.removeItem(at: url)
|
|
throw AudioRecorderError.tooShort
|
|
}
|
|
return Recording(url: url, duration: duration)
|
|
}
|
|
|
|
func cancel() {
|
|
let url = recorder?.url
|
|
recorder?.stop()
|
|
stopState()
|
|
if let url {
|
|
try? FileManager.default.removeItem(at: url)
|
|
}
|
|
}
|
|
|
|
private func stopState() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
recorder = nil
|
|
startedAt = nil
|
|
elapsed = 0
|
|
isRecording = false
|
|
#if os(iOS)
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
#endif
|
|
}
|
|
|
|
private func requestPermission() async -> Bool {
|
|
#if os(iOS)
|
|
let session = AVAudioApplication.shared
|
|
switch session.recordPermission {
|
|
case .granted:
|
|
return true
|
|
case .denied:
|
|
return false
|
|
case .undetermined:
|
|
return await withCheckedContinuation { continuation in
|
|
AVAudioApplication.requestRecordPermission { continuation.resume(returning: $0) }
|
|
}
|
|
@unknown default:
|
|
return false
|
|
}
|
|
#else
|
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
|
case .authorized:
|
|
return true
|
|
case .denied, .restricted:
|
|
return false
|
|
case .notDetermined:
|
|
return await AVCaptureDevice.requestAccess(for: .audio)
|
|
@unknown default:
|
|
return false
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private enum AudioRecorderError: LocalizedError {
|
|
case permissionDenied
|
|
case cannotStart
|
|
case notRecording
|
|
case tooShort
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .permissionDenied:
|
|
return "Разрешите Luma доступ к микрофону в системных настройках."
|
|
case .cannotStart:
|
|
return "Не удалось начать запись голоса."
|
|
case .notRecording:
|
|
return "Запись голоса не запущена."
|
|
case .tooShort:
|
|
return "Голосовое сообщение получилось слишком коротким."
|
|
}
|
|
}
|
|
}
|