0.10.6
This commit is contained in:
commit
92660a4ba0
228 files changed
+24496
No files matched your search
@@ -0,0 +1,147 @@
|
||||
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 "Голосовое сообщение получилось слишком коротким."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user