--------- Co-authored-by: intelligence <intelligence@debian.local> Co-authored-by: jourintown <jourintown@proton.me> Reviewed-on: #2
87 lines
2.5 KiB
Swift
87 lines
2.5 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
|
|
enum RecordingError: LocalizedError {
|
|
case microphoneDenied
|
|
case couldNotStart
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .microphoneDenied: "Microphone access is off. Enable it in System Settings."
|
|
case .couldNotStart: "The microphone could not start recording."
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class AudioRecorder: NSObject, AVAudioRecorderDelegate {
|
|
private var recorder: AVAudioRecorder?
|
|
private var meterTimer: Timer?
|
|
private(set) var fileURL: URL?
|
|
var onLevel: ((Double) -> Void)?
|
|
|
|
func start() async throws {
|
|
guard await Self.requestPermission() else { throw RecordingError.microphoneDenied }
|
|
let fileURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("transui-\(UUID().uuidString).wav")
|
|
self.fileURL = fileURL
|
|
let settings: [String: Any] = [
|
|
AVFormatIDKey: Int(kAudioFormatLinearPCM),
|
|
AVSampleRateKey: 16_000,
|
|
AVNumberOfChannelsKey: 1,
|
|
AVLinearPCMBitDepthKey: 16,
|
|
AVLinearPCMIsFloatKey: false,
|
|
AVLinearPCMIsBigEndianKey: false,
|
|
]
|
|
let recorder = try AVAudioRecorder(url: fileURL, settings: settings)
|
|
recorder.delegate = self
|
|
recorder.isMeteringEnabled = true
|
|
guard recorder.prepareToRecord(), recorder.record() else {
|
|
throw RecordingError.couldNotStart
|
|
}
|
|
self.recorder = recorder
|
|
meterTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in
|
|
Task { @MainActor in self?.updateLevel() }
|
|
}
|
|
}
|
|
|
|
func stop() -> URL? {
|
|
meterTimer?.invalidate()
|
|
meterTimer = nil
|
|
recorder?.stop()
|
|
recorder = nil
|
|
onLevel?(0)
|
|
let url = fileURL
|
|
fileURL = nil
|
|
return url
|
|
}
|
|
|
|
func cancel() {
|
|
let url = stop()
|
|
if let url { try? FileManager.default.removeItem(at: url) }
|
|
fileURL = nil
|
|
}
|
|
|
|
private func updateLevel() {
|
|
guard let recorder else { return }
|
|
recorder.updateMeters()
|
|
let decibels = Double(recorder.averagePower(forChannel: 0))
|
|
let normalized = max(0, min(1, pow(10, decibels / 32)))
|
|
onLevel?(normalized)
|
|
}
|
|
|
|
private static func requestPermission() async -> Bool {
|
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
|
case .authorized: true
|
|
case .denied, .restricted: false
|
|
case .notDetermined:
|
|
await withCheckedContinuation { continuation in
|
|
AVCaptureDevice.requestAccess(for: .audio) { allowed in
|
|
continuation.resume(returning: allowed)
|
|
}
|
|
}
|
|
@unknown default: false
|
|
}
|
|
}
|
|
}
|