Files
Luma/Sources/Watch/WatchVoiceRecorder.swift
2026-07-30 20:07:51 +07:00

206 lines
6.8 KiB
Swift

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 "Не удалось воспроизвести запись."
}
}
}