import AVFoundation import Combine import Foundation @MainActor final class WatchVoiceRecorder: NSObject, ObservableObject { struct Recording: Equatable { let url: URL let duration: TimeInterval } static let maximumDuration: TimeInterval = 60 @Published private(set) var isRecording = false @Published private(set) var isPlaying = false @Published private(set) var elapsed: TimeInterval = 0 @Published private(set) var recording: Recording? private var recorder: AVAudioRecorder? private var player: AVAudioPlayer? private var timer: Timer? private var startedAt: Date? func start() async throws { guard !isRecording else { return } discardRecording() guard await requestPermission() else { throw WatchVoiceRecorderError.permissionDenied } let audioSession = AVAudioSession.sharedInstance() do { try audioSession.setCategory(.playAndRecord, mode: .default) try audioSession.setActive(true) let directory = FileManager.default.temporaryDirectory .appendingPathComponent("LumaWatchRecordings", 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, AVEncoderBitRateKey: 32_000, AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue ] let recorder = try AVAudioRecorder(url: url, settings: settings) recorder.prepareToRecord() guard recorder.record(forDuration: Self.maximumDuration) else { throw WatchVoiceRecorderError.cannotStart } self.recorder = recorder startedAt = Date() elapsed = 0 isRecording = true timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in Task { @MainActor in guard let self, let startedAt = self.startedAt else { return } self.elapsed = min(Date().timeIntervalSince(startedAt), Self.maximumDuration) if !self.recorderIsActivelyRecording { _ = try? self.finish() } } } } catch { try? audioSession.setActive(false, options: .notifyOthersOnDeactivation) throw error } } @discardableResult func finish() throws -> Recording { guard let recorder, isRecording else { throw WatchVoiceRecorderError.notRecording } let duration = min(max(recorder.currentTime, elapsed), Self.maximumDuration) let url = recorder.url recorder.stop() stopRecordingState() guard duration >= 0.4 else { try? FileManager.default.removeItem(at: url) throw WatchVoiceRecorderError.tooShort } let recording = Recording(url: url, duration: duration) self.recording = recording return recording } func cancelRecording() { let url = recorder?.url recorder?.stop() stopRecordingState() if let url { try? FileManager.default.removeItem(at: url) } } func togglePlayback() throws { if isPlaying { stopPlayback() return } guard let recording else { return } let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.playback, mode: .spokenAudio) try audioSession.setActive(true) do { let player = try AVAudioPlayer(contentsOf: recording.url) player.delegate = self player.prepareToPlay() guard player.play() else { throw WatchVoiceRecorderError.cannotPlay } self.player = player isPlaying = true } catch { try? audioSession.setActive(false, options: .notifyOthersOnDeactivation) throw error } } func discardRecording() { stopPlayback() if let url = recording?.url { try? FileManager.default.removeItem(at: url) } recording = nil } func detachRecording() -> Recording? { stopPlayback() defer { recording = nil } return recording } func reset() { cancelRecording() discardRecording() } private var recorderIsActivelyRecording: Bool { recorder?.isRecording == true } private func stopRecordingState() { timer?.invalidate() timer = nil recorder = nil startedAt = nil elapsed = 0 isRecording = false try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } private func stopPlayback() { player?.stop() player = nil isPlaying = false try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } private func requestPermission() async -> Bool { switch AVAudioApplication.shared.recordPermission { case .granted: return true case .denied: return false case .undetermined: return await withCheckedContinuation { continuation in AVAudioApplication.requestRecordPermission { granted in continuation.resume(returning: granted) } } @unknown default: return false } } } extension WatchVoiceRecorder: AVAudioPlayerDelegate { nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { Task { @MainActor [weak self] in self?.stopPlayback() } } } private enum WatchVoiceRecorderError: LocalizedError { case permissionDenied case cannotStart case notRecording case tooShort case cannotPlay var errorDescription: String? { switch self { case .permissionDenied: return "Разрешите Luma доступ к микрофону в настройках Apple Watch." case .cannotStart: return "Не удалось начать запись на Apple Watch." case .notRecording: return "Запись голосового сообщения не запущена." case .tooShort: return "Голосовое сообщение получилось слишком коротким." case .cannotPlay: return "Не удалось воспроизвести запись." } } }