Files
Luma/Sources/Shared/Services/AudioMessageRecorder.swift
T
2026-07-30 20:07:51 +07:00

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 "Голосовое сообщение получилось слишком коротким."
}
}
}