vOOice/VoiceInk/Recorder.swift
2025-08-02 09:15:46 +05:45

211 lines
7.0 KiB
Swift

import Foundation
import AVFoundation
import CoreAudio
import os
@MainActor
class Recorder: ObservableObject {
private var recorder: AVAudioRecorder?
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "Recorder")
private let deviceManager = AudioDeviceManager.shared
private var deviceObserver: NSObjectProtocol?
private var isReconfiguring = false
private let mediaController = MediaController.shared
private let playbackController = PlaybackController.shared
@Published var audioMeter = AudioMeter(averagePower: 0, peakPower: 0)
private var audioLevelCheckTask: Task<Void, Never>?
private var hasDetectedAudioInCurrentSession = false
enum RecorderError: Error {
case couldNotStartRecording
}
init() {
setupDeviceChangeObserver()
}
private func setupDeviceChangeObserver() {
deviceObserver = AudioDeviceConfiguration.createDeviceChangeObserver { [weak self] in
Task {
await self?.handleDeviceChange()
}
}
}
private func handleDeviceChange() async {
guard !isReconfiguring else { return }
isReconfiguring = true
if recorder != nil {
let currentURL = recorder?.url
stopRecording()
if let url = currentURL {
do {
try await startRecording(toOutputFile: url)
} catch {
logger.error("❌ Failed to restart recording after device change: \(error.localizedDescription)")
}
}
}
isReconfiguring = false
}
private func configureAudioSession(with deviceID: AudioDeviceID) async throws {
try AudioDeviceConfiguration.setDefaultInputDevice(deviceID)
}
func startRecording(toOutputFile url: URL) async throws {
isReconfiguring = true
defer { isReconfiguring = false }
deviceManager.isRecordingActive = true
let currentDeviceID = deviceManager.getCurrentDevice()
let lastDeviceID = UserDefaults.standard.string(forKey: "lastUsedMicrophoneDeviceID")
if String(currentDeviceID) != lastDeviceID {
if let deviceName = deviceManager.availableDevices.first(where: { $0.id == currentDeviceID })?.name {
await MainActor.run {
NotificationManager.shared.showNotification(
title: "Using: \(deviceName)",
type: .info
)
}
}
}
UserDefaults.standard.set(String(currentDeviceID), forKey: "lastUsedMicrophoneDeviceID")
hasDetectedAudioInCurrentSession = false
Task {
await playbackController.pauseMedia()
await mediaController.muteSystemAudio()
}
let deviceID = deviceManager.getCurrentDevice()
if deviceID != 0 {
do {
try await configureAudioSession(with: deviceID)
} catch {
logger.warning("⚠️ Failed to configure audio session for device \(deviceID), attempting to continue: \(error.localizedDescription)")
}
}
let recordSettings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatLinearPCM),
AVSampleRateKey: 16000.0,
AVNumberOfChannelsKey: 1,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false
]
do {
recorder = try AVAudioRecorder(url: url, settings: recordSettings)
recorder?.isMeteringEnabled = true
if recorder?.record() == false {
logger.error("❌ Could not start recording")
throw RecorderError.couldNotStartRecording
}
audioLevelCheckTask?.cancel()
Task {
while recorder != nil {
updateAudioMeter()
try? await Task.sleep(nanoseconds: 33_000_000)
}
}
audioLevelCheckTask = Task {
let notificationChecks: [TimeInterval] = [2.0, 8.0]
for delay in notificationChecks {
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
if Task.isCancelled { return }
if self.hasDetectedAudioInCurrentSession {
return
}
await MainActor.run {
NotificationManager.shared.showNotification(
title: "No Audio Detected",
type: .warning
)
}
}
}
} catch {
logger.error("Failed to create audio recorder: \(error.localizedDescription)")
stopRecording()
throw RecorderError.couldNotStartRecording
}
}
func stopRecording() {
audioLevelCheckTask?.cancel()
recorder?.stop()
recorder = nil
audioMeter = AudioMeter(averagePower: 0, peakPower: 0)
Task {
// Complete system audio operations first
await mediaController.unmuteSystemAudio()
await playbackController.resumeMedia()
}
deviceManager.isRecordingActive = false
}
private func updateAudioMeter() {
guard let recorder = recorder else { return }
recorder.updateMeters()
let averagePower = recorder.averagePower(forChannel: 0)
let peakPower = recorder.peakPower(forChannel: 0)
let minVisibleDb: Float = -60.0
let maxVisibleDb: Float = 0.0
let normalizedAverage: Float
if averagePower < minVisibleDb {
normalizedAverage = 0.0
} else if averagePower >= maxVisibleDb {
normalizedAverage = 1.0
} else {
normalizedAverage = (averagePower - minVisibleDb) / (maxVisibleDb - minVisibleDb)
}
let normalizedPeak: Float
if peakPower < minVisibleDb {
normalizedPeak = 0.0
} else if peakPower >= maxVisibleDb {
normalizedPeak = 1.0
} else {
normalizedPeak = (peakPower - minVisibleDb) / (maxVisibleDb - minVisibleDb)
}
let newAudioMeter = AudioMeter(averagePower: Double(normalizedAverage), peakPower: Double(normalizedPeak))
if !hasDetectedAudioInCurrentSession && newAudioMeter.averagePower > 0.01 {
hasDetectedAudioInCurrentSession = true
}
audioMeter = newAudioMeter
}
deinit {
if let observer = deviceObserver {
NotificationCenter.default.removeObserver(observer)
}
}
}
struct AudioMeter: Equatable {
let averagePower: Double
let peakPower: Double
}