Files

1702 lines
57 KiB
Swift
Executable File

//
// RecordingClient.swift
// Hex
//
// Created by Kit Langton on 1/24/25.
//
import AppKit // For NSEvent media key simulation
import AVFoundation
import ComposableArchitecture
import CoreAudio
import Dependencies
import DependenciesMacros
import Foundation
import HexCore
private let recordingLogger = HexLog.recording
private let mediaLogger = HexLog.media
private typealias CoreAudioPropertyListenerBlock = @convention(block) (UInt32, UnsafePointer<AudioObjectPropertyAddress>) -> Void
/// Represents an audio input device
struct AudioInputDevice: Identifiable, Equatable {
var id: String
var name: String
var legacyID: String
}
@DependencyClient
struct RecordingClient {
var startRecording: @Sendable () async -> Void = {}
var stopRecording: @Sendable () async -> RecordingStopResult = { .ignored(.noActiveRecording) }
var requestMicrophoneAccess: @Sendable () async -> Bool = { false }
var observeAudioLevel: @Sendable () async -> AsyncStream<Meter> = { AsyncStream { _ in } }
var getAvailableInputDevices: @Sendable () async -> [AudioInputDevice] = { [] }
var getDefaultInputDeviceName: @Sendable () async -> String? = { nil }
var warmUpRecorder: @Sendable () async -> Void = {}
var cleanup: @Sendable () async -> Void = {}
}
extension RecordingClient: DependencyKey {
static var liveValue: Self {
let live = RecordingClientLive()
Task {
await live.startObservingSystemChanges()
}
return Self(
startRecording: { await live.startRecording() },
stopRecording: { await live.stopRecording() },
requestMicrophoneAccess: { await live.requestMicrophoneAccess() },
observeAudioLevel: { await live.observeAudioLevel() },
getAvailableInputDevices: { await live.getAvailableInputDevices() },
getDefaultInputDeviceName: { await live.getDefaultInputDeviceName() },
warmUpRecorder: { await live.warmUpRecorder() },
cleanup: { await live.cleanup() }
)
}
}
/// Simple structure representing audio metering values.
struct Meter: Equatable {
let averagePower: Double
let peakPower: Double
}
enum IgnoredRecordingStopReason: Equatable {
case staleSession
case noActiveRecording
}
enum RecordingFailure: Error, Equatable {
case captureWriteFailed(String)
case fallbackExportFailed(String)
case noCapturedAudio
}
extension RecordingFailure: LocalizedError {
var errorDescription: String? {
switch self {
case let .captureWriteFailed(message):
"Failed to write captured audio: \(message)"
case let .fallbackExportFailed(message):
"Failed to export recorded audio: \(message)"
case .noCapturedAudio:
"Recording stopped without captured audio."
}
}
}
enum RecordingStopResult: Equatable {
case captured(URL)
case ignored(IgnoredRecordingStopReason)
case failed(RecordingFailure)
}
// Define function pointer types for the MediaRemote functions.
typealias MRNowPlayingIsPlayingFunc = @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
typealias MRMediaRemoteSendCommandFunc = @convention(c) (Int32, CFDictionary?) -> Void
enum MediaRemoteCommand: Int32 {
case play = 0
case pause = 1
case togglePlayPause = 2
}
/// Wraps a few MediaRemote functions.
@Observable
class MediaRemoteController {
private var mediaRemoteHandle: UnsafeMutableRawPointer?
private var mrNowPlayingIsPlaying: MRNowPlayingIsPlayingFunc?
private var mrSendCommand: MRMediaRemoteSendCommandFunc?
init?() {
// Open the private framework.
guard let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_NOW) as UnsafeMutableRawPointer? else {
mediaLogger.error("Unable to open MediaRemote framework")
return nil
}
mediaRemoteHandle = handle
// Get pointer for the "is playing" function.
guard let playingPtr = dlsym(handle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying") else {
mediaLogger.error("Unable to find MRMediaRemoteGetNowPlayingApplicationIsPlaying symbol")
return nil
}
mrNowPlayingIsPlaying = unsafeBitCast(playingPtr, to: MRNowPlayingIsPlayingFunc.self)
if let commandPtr = dlsym(handle, "MRMediaRemoteSendCommand") {
mrSendCommand = unsafeBitCast(commandPtr, to: MRMediaRemoteSendCommandFunc.self)
} else {
mediaLogger.error("Unable to find MRMediaRemoteSendCommand symbol")
}
}
deinit {
if let handle = mediaRemoteHandle {
dlclose(handle)
}
}
/// Asynchronously refreshes the "is playing" status.
func isMediaPlaying() async -> Bool {
guard let isPlayingFunc = mrNowPlayingIsPlaying else { return false }
return await withCheckedContinuation { continuation in
isPlayingFunc(DispatchQueue.main) { isPlaying in
continuation.resume(returning: isPlaying)
}
}
}
func send(_ command: MediaRemoteCommand) -> Bool {
guard let sendCommand = mrSendCommand else {
return false
}
sendCommand(command.rawValue, nil)
return true
}
}
// Global instance of MediaRemoteController
private let mediaRemoteController = MediaRemoteController()
func isAudioPlayingOnDefaultOutput() async -> Bool {
// Refresh the state before checking
return await mediaRemoteController?.isMediaPlaying() ?? false
}
/// Check if an application is installed by looking for its bundle
private func isAppInstalled(bundleID: String) -> Bool {
let workspace = NSWorkspace.shared
return workspace.urlForApplication(withBundleIdentifier: bundleID) != nil
}
/// Cached list of installed media players (computed once at first access)
private let installedMediaPlayers: [String: String] = {
var result: [String: String] = [:]
if isAppInstalled(bundleID: "com.apple.Music") {
result["Music"] = "com.apple.Music"
}
if isAppInstalled(bundleID: "com.apple.iTunes") {
result["iTunes"] = "com.apple.iTunes"
}
if isAppInstalled(bundleID: "com.spotify.client") {
result["Spotify"] = "com.spotify.client"
}
if isAppInstalled(bundleID: "org.videolan.vlc") {
result["VLC"] = "org.videolan.vlc"
}
return result
}()
// Backoff to avoid spamming AppleScript errors on systems without controllable players
private var mediaControlErrorCount = 0
private var mediaControlDisabled = false
func pauseAllMediaApplications() async -> [String] {
if mediaControlDisabled { return [] }
// Use cached list of installed media players
if installedMediaPlayers.isEmpty {
return []
}
mediaLogger.debug("Installed media players: \(installedMediaPlayers.keys.joined(separator: ", "))")
// Create AppleScript that only targets installed players
var scriptParts: [String] = ["set pausedPlayers to {}"]
for (appName, _) in installedMediaPlayers {
if appName == "VLC" {
// VLC: check running, then pause if currently playing
scriptParts.append("""
try
if application \"VLC\" is running then
tell application \"VLC\"
if playing then
pause
set end of pausedPlayers to \"VLC\"
end if
end tell
end if
end try
""")
} else {
// Music / iTunes / Spotify: check running outside of tell, then query player state
scriptParts.append("""
try
if application \"\(appName)\" is running then
tell application \"\(appName)\"
if player state is playing then
pause
set end of pausedPlayers to \"\(appName)\"
end if
end tell
end if
end try
""")
}
}
scriptParts.append("return pausedPlayers")
let script = scriptParts.joined(separator: "\n\n")
let appleScript = NSAppleScript(source: script)
var error: NSDictionary?
guard let resultDescriptor = appleScript?.executeAndReturnError(&error) else {
if let error = error {
mediaLogger.error("Failed to pause media apps: \(error)")
mediaControlErrorCount += 1
if mediaControlErrorCount >= 3 { mediaControlDisabled = true }
}
return []
}
// Convert AppleScript list to Swift array
var pausedPlayers: [String] = []
let count = resultDescriptor.numberOfItems
if count > 0 {
for i in 1...count {
if let item = resultDescriptor.atIndex(i)?.stringValue {
pausedPlayers.append(item)
}
}
}
mediaLogger.notice("Paused media players: \(pausedPlayers.joined(separator: ", "))")
return pausedPlayers
}
func resumeMediaApplications(_ players: [String]) async {
guard !players.isEmpty else { return }
// Only attempt to resume players that are installed
let validPlayers = players.filter { installedMediaPlayers.keys.contains($0) }
if validPlayers.isEmpty {
return
}
// Create specific resume script for each player
var scriptParts: [String] = []
for player in validPlayers {
if player == "VLC" {
scriptParts.append("""
try
if application id \"org.videolan.vlc\" is running then
tell application id \"org.videolan.vlc\" to play
end if
end try
""")
} else {
scriptParts.append("""
try
if application \"\(player)\" is running then
tell application \"\(player)\" to play
end if
end try
""")
}
}
let script = scriptParts.joined(separator: "\n\n")
let appleScript = NSAppleScript(source: script)
var error: NSDictionary?
appleScript?.executeAndReturnError(&error)
if let error = error {
mediaLogger.error("Failed to resume media apps: \(error)")
}
}
/// Simulates a media key press (the Play/Pause key) by posting a system-defined NSEvent.
/// This toggles the state of the active media app.
private func sendMediaKey() {
let NX_KEYTYPE_PLAY: UInt32 = 16
func postKeyEvent(down: Bool) {
let flags: NSEvent.ModifierFlags = down ? .init(rawValue: 0xA00) : .init(rawValue: 0xB00)
let data1 = Int((NX_KEYTYPE_PLAY << 16) | (down ? 0xA << 8 : 0xB << 8))
if let event = NSEvent.otherEvent(with: .systemDefined,
location: .zero,
modifierFlags: flags,
timestamp: 0,
windowNumber: 0,
context: nil,
subtype: 8,
data1: data1,
data2: -1)
{
event.cgEvent?.post(tap: .cghidEventTap)
}
}
postKeyEvent(down: true)
postKeyEvent(down: false)
}
// MARK: - RecordingClientLive Implementation
/// A system transition that makes warm capture pointless (nobody dictates at a locked or
/// sleeping screen). Tracked as separate sources so a wake while the screen is still locked
/// stays suspended until the unlock arrives.
private enum CaptureSuspensionSource: String {
case systemSleep = "system-sleep"
case displaySleep = "display-sleep"
case screenLock = "screen-lock"
}
private enum ScreenLockNotifications {
static let locked = Notification.Name("com.apple.screenIsLocked")
static let unlocked = Notification.Name("com.apple.screenIsUnlocked")
}
actor RecordingClientLive {
private struct AudioHardwareObserver {
let selector: AudioObjectPropertySelector
let reason: String
let listener: CoreAudioPropertyListenerBlock
}
private enum RecordingBackend: String {
case captureEngine = "capture-engine"
case recorderFallback = "recorder-fallback"
}
private struct ActiveRecordingSession {
let startedAt: Date
let mode: CaptureRecordingMode
let backend: RecordingBackend
}
private var recorder: AVAudioRecorder?
private let recordingURL = FileManager.default.temporaryDirectory.appendingPathComponent("recording.wav")
private var isRecorderPrimedForNextSession = false
private var lastPrimedDeviceID: AudioDeviceID?
private var recordingSessionID: UUID?
private var activeRecordingSession: ActiveRecordingSession?
private var lastRecordingEndedAt: Date?
private var deferredCaptureRestartReason: String?
private var environmentChangeDebounceTask: Task<Void, Never>?
private var mediaControlTask: Task<Void, Never>?
private let recorderSettings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatLinearPCM),
AVSampleRateKey: 16000.0,
AVNumberOfChannelsKey: 1,
AVLinearPCMBitDepthKey: 32,
AVLinearPCMIsFloatKey: true,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false,
]
private let (meterStream, meterContinuation) = AsyncStream<Meter>.makeStream()
private var meterTask: Task<Void, Never>?
private lazy var captureController = SuperFastCaptureController(
meterContinuation: meterContinuation,
onEngineConfigurationChange: { [weak self] generation in
Task {
await self?.enqueueCaptureEnvironmentChange(
reason: "capture-engine-configuration-changed",
captureGeneration: generation
)
}
}
)
private var captureControllerDeviceID: AudioDeviceID?
private var captureControllerNeedsRestartReason: String?
private var notificationObservers: [NSObjectProtocol] = []
private var distributedNotificationObservers: [NSObjectProtocol] = []
private var audioHardwareObservers: [AudioHardwareObserver] = []
private var isObservingSystemChanges = false
/// Active reasons warm capture is suspended. The warm engine is torn down while any
/// source is active and rearmed when the last one clears, which also avoids a whole
/// class of stale-engine-across-sleep bugs.
private var captureSuspensionSources: Set<CaptureSuspensionSource> = []
private var isCaptureSuspended: Bool { !captureSuspensionSources.isEmpty }
@Shared(.hexSettings) var hexSettings: HexSettings
/// Tracks whether media was paused using the media key when recording started.
private var didPauseMedia: Bool = false
/// Tracks whether media was toggled via MediaRemote
private var didPauseViaMediaRemote: Bool = false
/// Tracks which specific media players were paused
private var pausedPlayers: [String] = []
/// Tracks previous system volume when muted for recording
private var previousVolume: Float?
/// Gets all available input devices on the system
func getAvailableInputDevices() async -> [AudioInputDevice] {
// Get all available audio devices
let devices = getAllAudioDevices()
var inputDevices: [AudioInputDevice] = []
// Filter to only input devices and convert to our model
for device in devices {
if deviceHasInput(deviceID: device),
let deviceUID = getDeviceUID(deviceID: device),
let deviceName = getDeviceName(deviceID: device) {
inputDevices.append(AudioInputDevice(id: deviceUID, name: deviceName, legacyID: String(device)))
}
}
return inputDevices
}
/// Gets the current system default input device name
func getDefaultInputDeviceName() async -> String? {
guard let deviceID = getDefaultInputDevice() else { return nil }
return getDeviceName(deviceID: deviceID)
}
// MARK: - Core Audio Helpers
/// Creates an AudioObjectPropertyAddress with common defaults.
private func audioPropertyAddress(
_ selector: AudioObjectPropertySelector,
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain
) -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: selector,
mScope: scope,
mElement: element
)
}
func startObservingSystemChanges() {
guard !isObservingSystemChanges else { return }
isObservingSystemChanges = true
let workspaceCenter = NSWorkspace.shared.notificationCenter
notificationObservers.append(
workspaceCenter.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main
) { _ in
Task { await self.resumeWarmCapture(source: .systemSleep, reason: "system-wake") }
}
)
notificationObservers.append(
workspaceCenter.addObserver(
forName: NSWorkspace.screensDidWakeNotification,
object: nil,
queue: .main
) { _ in
Task { await self.resumeWarmCapture(source: .displaySleep, reason: "display-wake") }
}
)
notificationObservers.append(
workspaceCenter.addObserver(
forName: NSWorkspace.willSleepNotification,
object: nil,
queue: .main
) { _ in
Task { await self.suspendWarmCapture(source: .systemSleep) }
}
)
notificationObservers.append(
workspaceCenter.addObserver(
forName: NSWorkspace.screensDidSleepNotification,
object: nil,
queue: .main
) { _ in
Task { await self.suspendWarmCapture(source: .displaySleep) }
}
)
let distributedCenter = DistributedNotificationCenter.default()
distributedNotificationObservers.append(
distributedCenter.addObserver(
forName: ScreenLockNotifications.locked,
object: nil,
queue: .main
) { _ in
Task { await self.suspendWarmCapture(source: .screenLock) }
}
)
distributedNotificationObservers.append(
distributedCenter.addObserver(
forName: ScreenLockNotifications.unlocked,
object: nil,
queue: .main
) { _ in
Task { await self.resumeWarmCapture(source: .screenLock, reason: "screen-unlocked") }
}
)
let center = NotificationCenter.default
notificationObservers.append(
center.addObserver(
forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasConnected"),
object: nil,
queue: .main
) { _ in
Task { await self.enqueueCaptureEnvironmentChange(reason: "capture-device-connected") }
}
)
notificationObservers.append(
center.addObserver(
forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasDisconnected"),
object: nil,
queue: .main
) { _ in
Task { await self.enqueueCaptureEnvironmentChange(reason: "capture-device-disconnected") }
}
)
installAudioHardwareObserver(
selector: kAudioHardwarePropertyDefaultInputDevice,
reason: "default-input-changed"
)
installAudioHardwareObserver(
selector: kAudioHardwarePropertyDefaultOutputDevice,
reason: "default-output-changed"
)
installAudioHardwareObserver(
selector: kAudioHardwarePropertyDevices,
reason: "audio-devices-changed"
)
recordingLogger.notice("Installed recording environment observers")
}
private func installAudioHardwareObserver(
selector: AudioObjectPropertySelector,
reason: String
) {
let listener: CoreAudioPropertyListenerBlock = { _, _ in
Task { await self.enqueueCaptureEnvironmentChange(reason: reason) }
}
var address = audioPropertyAddress(selector)
let status = AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject),
&address,
DispatchQueue.main,
listener
)
if status == noErr {
audioHardwareObservers.append(
AudioHardwareObserver(selector: selector, reason: reason, listener: listener)
)
} else {
recordingLogger.error("Failed to install audio observer reason=\(reason) status=\(status)")
}
}
private func enqueueCaptureEnvironmentChange(
reason: String,
captureGeneration: Int? = nil
) {
environmentChangeDebounceTask?.cancel()
environmentChangeDebounceTask = Task { [self] in
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled else { return }
if let captureGeneration,
!captureController.isCurrentGeneration(captureGeneration)
{
return
}
handleCaptureEnvironmentChange(reason: reason)
}
}
private func stopObservingSystemChanges() {
guard isObservingSystemChanges else { return }
isObservingSystemChanges = false
environmentChangeDebounceTask?.cancel()
environmentChangeDebounceTask = nil
for observer in notificationObservers {
NotificationCenter.default.removeObserver(observer)
NSWorkspace.shared.notificationCenter.removeObserver(observer)
}
notificationObservers.removeAll()
for observer in distributedNotificationObservers {
DistributedNotificationCenter.default().removeObserver(observer)
}
distributedNotificationObservers.removeAll()
for observer in audioHardwareObservers {
var address = audioPropertyAddress(observer.selector)
let status = AudioObjectRemovePropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject),
&address,
DispatchQueue.main,
observer.listener
)
if status != noErr {
recordingLogger.error("Failed to remove audio observer reason=\(observer.reason) status=\(status)")
}
}
audioHardwareObservers.removeAll()
}
/// Synchronous on purpose: no awaits means no actor reentrancy, so the recording-state
/// snapshot below cannot go stale while this method runs.
private func handleCaptureEnvironmentChange(reason: String) {
let currentInputDevice = getDefaultInputDevice()
let currentOutputDevice = getDefaultOutputDevice()
let isRecorderRecording = recorder?.isRecording == true
let isEngineRecording = captureController.isRecording
let isRecordingActive = isRecorderRecording || isEngineRecording
recordingLogger.notice(
"Capture environment changed reason=\(reason) activeRecording=\(isRecordingActive) input=\(self.describeDevice(currentInputDevice)) output=\(self.describeDevice(currentOutputDevice)) captureEngineArmed=\(self.captureController.isRunning) primed=\(self.isRecorderPrimedForNextSession)"
)
if isRecordingActive {
invalidatePrimedState()
if isEngineRecording {
let activeInputDevice = applyPreferredInputDevice()
// Only disturb an active recording when its capture path is actually affected:
// the input device changed, or the engine already died (configuration change).
// Output-only changes (plugging in headphones) must not cause an audio gap.
let inputChanged = activeInputDevice != captureControllerDeviceID
let engineDisrupted = !captureController.isRunning
guard inputChanged || engineDisrupted else {
recordingLogger.debug("Environment change does not affect active capture; ignoring reason=\(reason)")
return
}
// Rebuild the engine in place so capture continues onto the same file. Leaving the
// stale engine "running" against the old route silently captured nothing for the
// rest of the recording (#251, #252, #218, #226).
do {
try captureController.restartPreservingRecording(reason: reason)
captureControllerDeviceID = activeInputDevice
captureControllerNeedsRestartReason = nil
deferredCaptureRestartReason = nil
recordingLogger.notice(
"Rebuilt capture engine mid-recording reason=\(reason) input=\(self.describeDevice(activeInputDevice))"
)
return
} catch {
recordingLogger.error(
"Mid-recording capture engine rebuild failed reason=\(reason): \(error.localizedDescription); deferring restart"
)
}
}
deferredCaptureRestartReason = reason
recordingLogger.notice("Deferring capture restart until current recording stops reason=\(reason)")
return
}
deferredCaptureRestartReason = nil
if isCaptureSuspended {
tearDownWarmCapture(reason: "suspended-\(reason)")
recordingLogger.notice("Capture suspended (screen locked/asleep); deferring engine rebuild reason=\(reason)")
return
}
if hexSettings.superFastModeEnabled {
releaseRecorder(reason: "environment-change-\(reason)")
let activeInputDevice = applyPreferredInputDevice()
// Skip the rebuild when nothing that matters changed. Device events arrive in
// bursts (a Bluetooth connect fires devices-changed, default-input-changed,
// default-output-changed, and more over 1-2s); once the first event settles the
// engine on the right device, the rest become no-ops here.
if captureController.isRunning,
activeInputDevice == captureControllerDeviceID,
captureControllerNeedsRestartReason == nil
{
recordingLogger.debug("Capture engine unaffected by environment change; skipping rebuild reason=\(reason)")
return
}
do {
// Rebuild immediately instead of deferring to the next recording: a stale engine
// left running against the old route kept coreaudiod churning while idle (#209)
// and made the first post-change recording unreliable (#251, #252).
try ensureCaptureControllerReady(
for: activeInputDevice,
reason: "environment-change-\(reason)",
forceRestart: true
)
captureControllerNeedsRestartReason = nil
recordingLogger.notice(
"Rebuilt capture engine after environment change reason=\(reason) input=\(self.describeDevice(activeInputDevice))"
)
} catch {
stopCaptureController(reason: "environment-rebuild-failed-\(reason)")
captureControllerNeedsRestartReason = reason
recordingLogger.error(
"Capture engine rebuild failed reason=\(reason): \(error.localizedDescription); will retry on next recording"
)
}
return
}
_ = applyPreferredInputDevice()
stopCaptureController(reason: reason)
releaseRecorder(reason: "environment-change-\(reason)")
recordingLogger.debug("Standard mode uses on-demand capture startup after reason=\(reason)")
}
/// Records that a lock/sleep transition happened and tears down the warm engine unless a
/// recording is in flight (the recording keeps running; teardown happens after it stops
/// via finalizeCaptureStateAfterRecording).
private func suspendWarmCapture(source: CaptureSuspensionSource) {
guard captureSuspensionSources.insert(source).inserted else { return }
guard recorder?.isRecording != true, !captureController.isRecording else {
recordingLogger.notice("Lock/sleep during active recording; capture continues, suspending after stop source=\(source.rawValue)")
return
}
tearDownWarmCapture(reason: "suspend-\(source.rawValue)")
recordingLogger.notice("Suspended warm capture source=\(source.rawValue)")
}
private func resumeWarmCapture(source: CaptureSuspensionSource, reason: String) {
captureSuspensionSources.remove(source)
guard !isCaptureSuspended else {
// Woke up but e.g. the screen is still locked; stay suspended until unlock.
recordingLogger.notice("Still suspended after \(reason); remaining sources=\(self.captureSuspensionSources.map(\.rawValue).joined(separator: ","))")
return
}
recordingLogger.notice("Resuming warm capture reason=\(reason)")
enqueueCaptureEnvironmentChange(reason: reason)
}
private func tearDownWarmCapture(reason: String) {
captureControllerNeedsRestartReason = reason
guard captureController.isRunning || recorder != nil else { return }
stopCaptureController(reason: reason)
releaseRecorder(reason: reason)
}
/// Restores idle capture state after a recording ends: applies any environment change
/// that arrived mid-recording, then re-suspends if the screen locked or slept while the
/// recording was running.
private func finalizeCaptureStateAfterRecording() {
if let deferredCaptureRestartReason {
recordingLogger.notice("Applying deferred capture restart reason=\(deferredCaptureRestartReason)")
handleCaptureEnvironmentChange(reason: "deferred-\(deferredCaptureRestartReason)")
}
if isCaptureSuspended {
tearDownWarmCapture(reason: "post-recording-suspend")
recordingLogger.notice("Suspended warm capture after recording (screen locked/asleep)")
}
}
/// Get all available audio devices
private func getAllAudioDevices() -> [AudioDeviceID] {
var propertySize: UInt32 = 0
var address = audioPropertyAddress(kAudioHardwarePropertyDevices)
// Get the property data size
var status = AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject),
&address,
0,
nil,
&propertySize
)
if status != 0 {
recordingLogger.error("AudioObjectGetPropertyDataSize failed: \(status)")
return []
}
// Calculate device count
let deviceCount = Int(propertySize) / MemoryLayout<AudioDeviceID>.size
var deviceIDs = [AudioDeviceID](repeating: 0, count: deviceCount)
// Get the device IDs
status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&address,
0,
nil,
&propertySize,
&deviceIDs
)
if status != 0 {
recordingLogger.error("AudioObjectGetPropertyData failed while listing devices: \(status)")
return []
}
return deviceIDs
}
/// Get device name for the given device ID
private func getDeviceName(deviceID: AudioDeviceID) -> String? {
getDeviceStringProperty(deviceID: deviceID, selector: kAudioDevicePropertyDeviceNameCFString)
}
/// Get the persistent device UID for the given device ID
private func getDeviceUID(deviceID: AudioDeviceID) -> String? {
getDeviceStringProperty(deviceID: deviceID, selector: kAudioDevicePropertyDeviceUID)
}
private func getDeviceStringProperty(
deviceID: AudioDeviceID,
selector: AudioObjectPropertySelector
) -> String? {
var address = audioPropertyAddress(selector)
var deviceName: CFString? = nil
var size = UInt32(MemoryLayout<CFString?>.size)
let deviceNamePtr: UnsafeMutableRawPointer = .allocate(byteCount: Int(size), alignment: MemoryLayout<CFString?>.alignment)
defer { deviceNamePtr.deallocate() }
let status = AudioObjectGetPropertyData(
deviceID,
&address,
0,
nil,
&size,
deviceNamePtr
)
if status == 0 {
deviceName = deviceNamePtr.load(as: CFString?.self)
}
if status != 0 {
recordingLogger.error("Failed to fetch device property \(selector): \(status)")
return nil
}
return deviceName as String?
}
/// Check if device has input capabilities
private func deviceHasInput(deviceID: AudioDeviceID) -> Bool {
var address = audioPropertyAddress(kAudioDevicePropertyStreamConfiguration, scope: kAudioDevicePropertyScopeInput)
var propertySize: UInt32 = 0
let status = AudioObjectGetPropertyDataSize(
deviceID,
&address,
0,
nil,
&propertySize
)
if status != 0 {
return false
}
let bufferList = UnsafeMutablePointer<AudioBufferList>.allocate(capacity: Int(propertySize))
defer { bufferList.deallocate() }
let getStatus = AudioObjectGetPropertyData(
deviceID,
&address,
0,
nil,
&propertySize,
bufferList
)
if getStatus != 0 {
return false
}
// Check if we have any input channels
let buffersPointer = UnsafeMutableAudioBufferListPointer(bufferList)
return buffersPointer.reduce(0) { $0 + Int($1.mNumberChannels) } > 0
}
/// Set device as the default input device
private func setInputDevice(deviceID: AudioDeviceID) {
var device = deviceID
let size = UInt32(MemoryLayout<AudioDeviceID>.size)
var address = audioPropertyAddress(kAudioHardwarePropertyDefaultInputDevice)
let status = AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&address,
0,
nil,
size,
&device
)
if status != 0 {
recordingLogger.error("Failed to set default input device: \(status)")
} else {
recordingLogger.notice("Selected input device set to \(deviceID)")
}
}
func requestMicrophoneAccess() async -> Bool {
await AVCaptureDevice.requestAccess(for: .audio)
}
// MARK: - Input Device Query
/// Gets the current default input device ID
private func getDefaultInputDevice() -> AudioDeviceID? {
var deviceID = AudioDeviceID(0)
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
var address = audioPropertyAddress(kAudioHardwarePropertyDefaultInputDevice)
let status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&address,
0,
nil,
&size,
&deviceID
)
if status != 0 {
recordingLogger.error("Failed to get default input device: \(status)")
return nil
}
return deviceID
}
private func resolvePreferredInputDevice() -> AudioDeviceID? {
guard let selectedMicrophoneID = hexSettings.selectedMicrophoneID else { return nil }
if let deviceID = getDeviceID(uid: selectedMicrophoneID),
deviceHasInput(deviceID: deviceID) {
return deviceID
}
if let legacyDeviceID = AudioDeviceID(selectedMicrophoneID),
deviceHasInput(deviceID: legacyDeviceID) {
return legacyDeviceID
}
recordingLogger.notice("Selected device \(selectedMicrophoneID) missing; using system default")
return nil
}
private func getDeviceID(uid: String) -> AudioDeviceID? {
var address = audioPropertyAddress(kAudioHardwarePropertyDeviceForUID)
var deviceUID = uid as CFString
var deviceID = AudioDeviceID(0)
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
let status = withUnsafePointer(to: &deviceUID) { pointer in
AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&address,
UInt32(MemoryLayout<CFString>.size),
pointer,
&size,
&deviceID
)
}
guard status == noErr, deviceID != kAudioObjectUnknown else { return nil }
return deviceID
}
private func formatDuration(_ duration: TimeInterval?) -> String {
guard let duration else { return "n/a" }
return String(format: "%.3fs", duration)
}
private func describeDevice(_ deviceID: AudioDeviceID?) -> String {
guard let deviceID else { return "none" }
if let name = getDeviceName(deviceID: deviceID) {
return "\(name) [\(deviceID)]"
}
return "unknown [\(deviceID)]"
}
private func logRecordingStartRequest(mode: CaptureRecordingMode, inputDeviceID: AudioDeviceID?) {
let idleDuration = lastRecordingEndedAt.map { Date().timeIntervalSince($0) }
let outputDeviceID = getDefaultOutputDevice()
recordingLogger.notice(
"Recording requested mode=\(mode.rawValue) idle=\(self.formatDuration(idleDuration)) input=\(self.describeDevice(inputDeviceID)) output=\(self.describeDevice(outputDeviceID)) fallbackPrimed=\(self.isRecorderPrimedForNextSession)"
)
}
private func currentCaptureMode() -> CaptureRecordingMode {
hexSettings.superFastModeEnabled ? .superFast : .standard
}
@discardableResult
private func applyPreferredInputDevice() -> AudioDeviceID? {
let targetDeviceID = resolvePreferredInputDevice()
let currentDefaultDevice = getDefaultInputDevice()
if let primedDevice = lastPrimedDeviceID, primedDevice != currentDefaultDevice {
recordingLogger.notice("Default input changed from \(primedDevice) to \(currentDefaultDevice ?? 0); invalidating primed state")
invalidatePrimedState()
}
if let targetDeviceID {
if targetDeviceID != currentDefaultDevice {
recordingLogger.notice("Switching input device from \(currentDefaultDevice ?? 0) to \(targetDeviceID)")
setInputDevice(deviceID: targetDeviceID)
invalidatePrimedState()
} else {
recordingLogger.debug("Device \(targetDeviceID) already set as default, skipping setInputDevice()")
}
} else {
recordingLogger.debug("Using system default microphone")
}
return getDefaultInputDevice()
}
private func makeCaptureRecordingURL() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent("hex-capture-\(UUID().uuidString).wav")
}
nonisolated static func shouldIgnoreStopRequest(
snapshotSessionID: UUID?,
currentSessionID: UUID?
) -> Bool {
guard let snapshotSessionID else { return false }
return currentSessionID != snapshotSessionID
}
private func ensureCaptureControllerReady(
for deviceID: AudioDeviceID?,
reason: String,
forceRestart: Bool = false
) throws {
if forceRestart || captureControllerDeviceID != deviceID {
recordingLogger.notice(
"Restarting capture engine reason=\(reason) previousInput=\(self.describeDevice(self.captureControllerDeviceID)) newInput=\(self.describeDevice(deviceID)) force=\(forceRestart)"
)
stopCaptureController(reason: forceRestart ? "restart-\(reason)" : "input-device-changed")
}
try captureController.startIfNeeded(
reason: reason,
keepWarmBuffer: currentCaptureMode().keepsWarmBuffer
)
captureControllerDeviceID = deviceID
}
private func ensureCaptureControllerReadyAfterDeferredRestart(
for deviceID: AudioDeviceID?,
reason: String
) throws {
let deferredReason = captureControllerNeedsRestartReason
try ensureCaptureControllerReady(
for: deviceID,
reason: deferredReason.map { "deferred-\($0)-\(reason)" } ?? reason,
forceRestart: deferredReason != nil
)
captureControllerNeedsRestartReason = nil
}
private func stopCaptureController(reason: String) {
captureController.stop(reason: reason)
captureControllerDeviceID = nil
}
private func releaseRecorder(reason: String) {
if recorder != nil {
recordingLogger.notice(
"Releasing recorder reason=\(reason) primed=\(self.isRecorderPrimedForNextSession) input=\(self.describeDevice(self.lastPrimedDeviceID))"
)
}
stopMeterTask()
if recorder?.isRecording == true {
recorder?.stop()
}
recorder = nil
invalidatePrimedState()
}
// MARK: - Input Device Mute Detection & Fix
/// Checks if the input device is muted at the Core Audio device level
private func isInputDeviceMuted(_ deviceID: AudioDeviceID) -> Bool {
var address = audioPropertyAddress(kAudioDevicePropertyMute, scope: kAudioDevicePropertyScopeInput)
var muted: UInt32 = 0
var size = UInt32(MemoryLayout<UInt32>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &muted)
if status != noErr {
// Property not supported on this device
return false
}
return muted == 1
}
/// Unmutes the input device at the Core Audio device level
private func unmuteInputDevice(_ deviceID: AudioDeviceID) {
var address = audioPropertyAddress(kAudioDevicePropertyMute, scope: kAudioDevicePropertyScopeInput)
var muted: UInt32 = 0
let size = UInt32(MemoryLayout<UInt32>.size)
let status = AudioObjectSetPropertyData(deviceID, &address, 0, nil, size, &muted)
if status == noErr {
recordingLogger.warning("Input device \(deviceID) was muted at device level - automatically unmuted")
} else {
recordingLogger.error("Failed to unmute input device \(deviceID): \(status)")
}
}
/// Checks and fixes muted input device before recording
private func ensureInputDeviceUnmuted() {
guard let deviceID = getDefaultInputDevice() else { return }
if isInputDeviceMuted(deviceID) {
recordingLogger.error("⚠️ Input device \(deviceID) is MUTED at Core Audio level! This causes silent recordings.")
unmuteInputDevice(deviceID)
}
}
// MARK: - Volume Control
/// Mutes system volume and returns the previous volume level
private func muteSystemVolume() async -> Float {
let currentVolume = getSystemVolume()
setSystemVolume(0)
recordingLogger.notice("Muted system volume (was \(String(format: "%.2f", currentVolume)))")
return currentVolume
}
/// Restores system volume to the specified level
private func restoreSystemVolume(_ volume: Float) async {
setSystemVolume(volume)
recordingLogger.notice("Restored system volume to \(String(format: "%.2f", volume))")
}
/// Gets the default output device ID
private func getDefaultOutputDevice() -> AudioDeviceID? {
var deviceID = AudioDeviceID(0)
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
var address = audioPropertyAddress(kAudioHardwarePropertyDefaultOutputDevice)
let status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&address,
0,
nil,
&size,
&deviceID
)
if status != 0 {
recordingLogger.error("Failed to get default output device: \(status)")
return nil
}
return deviceID
}
/// Gets the current system output volume (0.0 to 1.0)
private func getSystemVolume() -> Float {
guard let deviceID = getDefaultOutputDevice() else {
return 0.0
}
var volume: Float32 = 0.0
var size = UInt32(MemoryLayout<Float32>.size)
var address = audioPropertyAddress(kAudioHardwareServiceDeviceProperty_VirtualMainVolume, scope: kAudioDevicePropertyScopeOutput)
let status = AudioObjectGetPropertyData(
deviceID,
&address,
0,
nil,
&size,
&volume
)
if status != 0 {
recordingLogger.error("Failed to get system volume: \(status)")
return 0.0
}
return volume
}
/// Sets the system output volume (0.0 to 1.0)
private func setSystemVolume(_ volume: Float) {
guard let deviceID = getDefaultOutputDevice() else {
return
}
var newVolume = volume
let size = UInt32(MemoryLayout<Float32>.size)
var address = audioPropertyAddress(kAudioHardwareServiceDeviceProperty_VirtualMainVolume, scope: kAudioDevicePropertyScopeOutput)
let status = AudioObjectSetPropertyData(
deviceID,
&address,
0,
nil,
size,
&newVolume
)
if status != 0 {
recordingLogger.error("Failed to set system volume: \(status)")
}
}
func startRecording() async {
let sessionID = UUID()
recordingSessionID = sessionID
// A pending environment-change debounce is superseded: the start path below applies
// device preferences and re-arms the engine itself, and letting the stale task fire
// into the new recording would force a pointless mid-recording rebuild.
environmentChangeDebounceTask?.cancel()
environmentChangeDebounceTask = nil
mediaControlTask?.cancel()
mediaControlTask = nil
// Handle audio behavior based on user preference
switch hexSettings.recordingAudioBehavior {
case .pauseMedia:
// Pause media in background - don't block recording from starting
mediaControlTask = Task { [sessionID] in
guard await self.isCurrentSession(sessionID) else { return }
if await self.pauseUsingMediaRemoteIfPossible(sessionID: sessionID) {
return
}
// First, pause all media applications using their AppleScript interface.
let paused = await pauseAllMediaApplications()
guard await self.isCurrentSession(sessionID) else {
await resumeMediaApplications(paused)
return
}
await self.updatePausedPlayers(paused, sessionID: sessionID)
// If no specific players were paused, pause generic media using the media key.
guard await self.isCurrentSession(sessionID) else { return }
if paused.isEmpty {
if await isAudioPlayingOnDefaultOutput() {
guard await self.isCurrentSession(sessionID) else { return }
mediaLogger.notice("Detected active audio on default output; sending media pause")
await MainActor.run {
sendMediaKey()
}
await self.setDidPauseMedia(true, sessionID: sessionID)
mediaLogger.notice("Paused media via media key fallback")
}
} else {
mediaLogger.notice("Paused media players: \(paused.joined(separator: ", "))")
}
}
case .mute:
// Mute system volume in background
mediaControlTask = Task { [sessionID] in
guard await self.isCurrentSession(sessionID) else { return }
let volume = await self.muteSystemVolume()
guard await self.isCurrentSession(sessionID) else {
await self.restoreSystemVolume(volume)
return
}
await self.setPreviousVolume(volume, sessionID: sessionID)
}
case .doNothing:
// No audio handling
break
}
let activeInputDevice = applyPreferredInputDevice()
// Check the actual active device after applying the user's preferred input.
ensureInputDeviceUnmuted()
let mode = currentCaptureMode()
logRecordingStartRequest(mode: mode, inputDeviceID: activeInputDevice)
let startRequestAt = Date()
do {
try ensureCaptureControllerReadyAfterDeferredRestart(for: activeInputDevice, reason: "startRecording")
let recordingURL = makeCaptureRecordingURL()
try captureController.beginRecording(to: recordingURL, requestedAt: startRequestAt, mode: mode)
let startedAt = Date()
activeRecordingSession = ActiveRecordingSession(
startedAt: startedAt,
mode: mode,
backend: .captureEngine
)
recordingLogger.notice(
"Recording started mode=\(mode.rawValue) backend=\(RecordingBackend.captureEngine.rawValue) startup=\(self.formatDuration(startedAt.timeIntervalSince(startRequestAt)))"
)
return
} catch {
recordingLogger.error("Failed to start capture engine for mode=\(mode.rawValue): \(error.localizedDescription); falling back to AVAudioRecorder")
stopCaptureController(reason: "capture-engine-start-failed")
}
do {
let recorder = try ensureRecorderReadyForRecording()
let recordCallStartedAt = Date()
guard recorder.record() else {
recordingLogger.error("AVAudioRecorder refused to start recording")
endRecordingSession()
return
}
let startedAt = Date()
activeRecordingSession = ActiveRecordingSession(
startedAt: startedAt,
mode: mode,
backend: .recorderFallback
)
startMeterTask()
recordingLogger.notice(
"Recording started mode=\(mode.rawValue) backend=\(RecordingBackend.recorderFallback.rawValue) recordCall=\(self.formatDuration(Date().timeIntervalSince(recordCallStartedAt))) totalStart=\(self.formatDuration(startedAt.timeIntervalSince(startRequestAt)))"
)
} catch {
recordingLogger.error("Failed to start recording: \(error.localizedDescription)")
clearActiveRecordingMetadata()
endRecordingSession()
}
}
func stopRecording() async -> RecordingStopResult {
let stopSessionID = recordingSessionID
let activeSession = activeRecordingSession
if activeSession?.backend == .captureEngine || captureController.isRecording {
let stopTimingEstimate = captureController.stopTimingEstimate
recordingLogger.debug(
"Waiting \(self.formatDuration(stopTimingEstimate.gracePeriod)) before finalizing capture-engine recording callbackInterval=\(self.formatDuration(stopTimingEstimate.callbackInterval)) bufferDuration=\(self.formatDuration(stopTimingEstimate.bufferDuration))"
)
try? await Task.sleep(for: .milliseconds(Int((stopTimingEstimate.gracePeriod * 1000).rounded())))
if Self.shouldIgnoreStopRequest(
snapshotSessionID: stopSessionID,
currentSessionID: recordingSessionID
) {
recordingLogger.notice("Ignoring stale stop request after a newer recording session started")
return .ignored(.staleSession)
}
}
switch captureController.finishRecording(clearBuffer: currentCaptureMode() == .superFast) {
case let .captured(captureURL):
let stoppedAt = Date()
let session = activeSession ?? ActiveRecordingSession(
startedAt: stoppedAt,
mode: currentCaptureMode(),
backend: .captureEngine
)
let recordingDuration = stoppedAt.timeIntervalSince(session.startedAt)
stopMeterTask()
endRecordingSession()
clearActiveRecordingMetadata()
lastRecordingEndedAt = stoppedAt
recordingLogger.notice(
"Recording stopped mode=\(session.mode.rawValue) backend=\(session.backend.rawValue) duration=\(self.formatDuration(recordingDuration))"
)
if !hexSettings.superFastModeEnabled {
stopCaptureController(reason: "mode-disabled-after-stop")
releaseRecorder(reason: "capture-engine-stop")
}
finalizeCaptureStateAfterRecording()
await resumeMediaIfNeeded()
return .captured(captureURL)
case let .failed(error):
let stoppedAt = Date()
stopMeterTask()
endRecordingSession()
clearActiveRecordingMetadata()
lastRecordingEndedAt = stoppedAt
if !hexSettings.superFastModeEnabled {
stopCaptureController(reason: "mode-disabled-after-stop-failed")
releaseRecorder(reason: "capture-engine-stop-failed")
}
finalizeCaptureStateAfterRecording()
await resumeMediaIfNeeded()
return .failed(error)
case .idle:
break
}
let stoppedAt = Date()
let session = activeSession ?? ActiveRecordingSession(
startedAt: stoppedAt,
mode: currentCaptureMode(),
backend: .recorderFallback
)
let recordingDuration = stoppedAt.timeIntervalSince(session.startedAt)
let wasRecording = recorder?.isRecording == true
guard session.backend == .recorderFallback, wasRecording else {
recordingLogger.notice("stopRecording() called without an active recorder fallback; skipping stale recording.wav export")
stopMeterTask()
endRecordingSession()
clearActiveRecordingMetadata()
lastRecordingEndedAt = stoppedAt
finalizeCaptureStateAfterRecording()
await resumeMediaIfNeeded()
return .ignored(.noActiveRecording)
}
recorder?.stop()
stopMeterTask()
endRecordingSession()
clearActiveRecordingMetadata()
lastRecordingEndedAt = stoppedAt
recordingLogger.notice("Recording stopped mode=\(session.mode.rawValue) backend=\(session.backend.rawValue) duration=\(self.formatDuration(recordingDuration))")
let exportedURL: URL
do {
exportedURL = try duplicateCurrentRecording()
} catch {
isRecorderPrimedForNextSession = false
recordingLogger.error("Failed to copy recording: \(error.localizedDescription)")
releaseRecorder(reason: "fallback-export-failed")
FileManager.default.removeItemIfExists(at: recordingURL)
finalizeCaptureStateAfterRecording()
await resumeMediaIfNeeded()
return .failed(.fallbackExportFailed(error.localizedDescription))
}
releaseRecorder(reason: "fallback-stop")
if !hexSettings.superFastModeEnabled {
stopCaptureController(reason: "standard-stop")
}
finalizeCaptureStateAfterRecording()
await resumeMediaIfNeeded()
return .captured(exportedURL)
}
private func resumeMediaIfNeeded() async {
let playersToResume = pausedPlayers
let shouldResumeMedia = didPauseMedia
let shouldResumeViaMediaRemote = didPauseViaMediaRemote
let volumeToRestore = previousVolume
clearMediaState()
// Restore volume if it was muted
if let volume = volumeToRestore {
await restoreSystemVolume(volume)
}
// Resume media if we previously paused specific players
else if !playersToResume.isEmpty {
mediaLogger.notice("Resuming players: \(playersToResume.joined(separator: ", "))")
await resumeMediaApplications(playersToResume)
}
else if shouldResumeViaMediaRemote {
if mediaRemoteController?.send(.play) == true {
mediaLogger.notice("Resuming media via MediaRemote")
} else {
mediaLogger.error("Failed to resume via MediaRemote; falling back to media key")
await MainActor.run {
sendMediaKey()
}
}
}
// Resume generic media if we paused it with the media key
else if shouldResumeMedia {
await MainActor.run {
sendMediaKey()
}
mediaLogger.notice("Resuming media via media key")
}
}
// Actor state update helpers
private func isCurrentSession(_ sessionID: UUID) -> Bool {
recordingSessionID == sessionID
}
private func endRecordingSession() {
recordingSessionID = nil
mediaControlTask?.cancel()
mediaControlTask = nil
}
private func clearActiveRecordingMetadata() {
activeRecordingSession = nil
}
private func invalidatePrimedState() {
isRecorderPrimedForNextSession = false
lastPrimedDeviceID = nil
}
private func updatePausedPlayers(_ players: [String], sessionID: UUID) {
guard recordingSessionID == sessionID else { return }
pausedPlayers = players
}
private func setDidPauseMedia(_ value: Bool, sessionID: UUID) {
guard recordingSessionID == sessionID else { return }
didPauseMedia = value
}
private func setDidPauseViaMediaRemote(_ value: Bool, sessionID: UUID) {
guard recordingSessionID == sessionID else { return }
didPauseViaMediaRemote = value
}
private func setPreviousVolume(_ volume: Float, sessionID: UUID) {
guard recordingSessionID == sessionID else { return }
previousVolume = volume
}
private func clearMediaState() {
pausedPlayers = []
didPauseMedia = false
didPauseViaMediaRemote = false
previousVolume = nil
}
@discardableResult
private func pauseUsingMediaRemoteIfPossible(sessionID: UUID) async -> Bool {
guard let controller = mediaRemoteController else {
return false
}
let isPlaying = await controller.isMediaPlaying()
guard isPlaying else {
return false
}
guard isCurrentSession(sessionID), !Task.isCancelled else { return false }
guard controller.send(.pause) else {
mediaLogger.error("Failed to send MediaRemote pause command")
return false
}
setDidPauseViaMediaRemote(true, sessionID: sessionID)
mediaLogger.notice("Paused media via MediaRemote")
return true
}
private enum RecorderPreparationError: Error {
case failedToPrepareRecorder
case missingRecordingOnDisk
}
private func ensureRecorderReadyForRecording() throws -> AVAudioRecorder {
let recorder = try recorderOrCreate()
if !isRecorderPrimedForNextSession {
recordingLogger.notice("Recorder NOT primed, calling prepareToRecord() now")
guard recorder.prepareToRecord() else {
throw RecorderPreparationError.failedToPrepareRecorder
}
} else {
recordingLogger.notice("Recorder already primed, skipping prepareToRecord()")
}
isRecorderPrimedForNextSession = false
return recorder
}
private func recorderOrCreate() throws -> AVAudioRecorder {
if let recorder {
return recorder
}
let recorder = try AVAudioRecorder(url: recordingURL, settings: recorderSettings)
recorder.isMeteringEnabled = true
self.recorder = recorder
return recorder
}
private func duplicateCurrentRecording() throws -> URL {
let fm = FileManager.default
guard fm.fileExists(atPath: recordingURL.path) else {
throw RecorderPreparationError.missingRecordingOnDisk
}
let exportURL = recordingURL
.deletingLastPathComponent()
.appendingPathComponent("hex-recording-\(UUID().uuidString).wav")
if fm.fileExists(atPath: exportURL.path) {
try fm.removeItem(at: exportURL)
}
try fm.copyItem(at: recordingURL, to: exportURL)
return exportURL
}
private func primeRecorderForNextSession() throws {
let recorder = try recorderOrCreate()
guard recorder.prepareToRecord() else {
isRecorderPrimedForNextSession = false
lastPrimedDeviceID = nil
throw RecorderPreparationError.failedToPrepareRecorder
}
isRecorderPrimedForNextSession = true
lastPrimedDeviceID = getDefaultInputDevice()
recordingLogger.debug("Recorder primed for device \(self.lastPrimedDeviceID ?? 0)")
}
func startMeterTask() {
meterTask = Task {
while !Task.isCancelled, let r = self.recorder, r.isRecording {
r.updateMeters()
let averagePower = r.averagePower(forChannel: 0)
let averageNormalized = pow(10, averagePower / 20.0)
let peakPower = r.peakPower(forChannel: 0)
let peakNormalized = pow(10, peakPower / 20.0)
meterContinuation.yield(Meter(averagePower: Double(averageNormalized), peakPower: Double(peakNormalized)))
try? await Task.sleep(for: .milliseconds(100))
}
}
}
func stopMeterTask() {
meterTask?.cancel()
meterTask = nil
}
func observeAudioLevel() -> AsyncStream<Meter> {
meterStream
}
func warmUpRecorder() async {
guard activeRecordingSession == nil, recorder?.isRecording != true, !captureController.isRecording else {
recordingLogger.notice("Skipping recorder warm-up while recording is active")
return
}
guard !isCaptureSuspended else {
recordingLogger.notice("Skipping recorder warm-up while capture is suspended (screen locked/asleep)")
return
}
let activeInputDevice = applyPreferredInputDevice()
if hexSettings.superFastModeEnabled {
releaseRecorder(reason: "warm-up-super-fast")
do {
try ensureCaptureControllerReadyAfterDeferredRestart(for: activeInputDevice, reason: "warmUpRecorder")
} catch {
recordingLogger.error("Failed to arm capture engine for super fast mode: \(error.localizedDescription)")
}
return
}
stopCaptureController(reason: "warm-up-standard")
releaseRecorder(reason: "warm-up-standard")
recordingLogger.debug("Standard mode uses on-demand capture engine startup; skipping idle recorder priming")
}
/// Release recorder resources. Call on app termination.
func cleanup() async {
endRecordingSession()
await resumeMediaIfNeeded()
stopObservingSystemChanges()
stopCaptureController(reason: "cleanup")
releaseRecorder(reason: "cleanup")
recordingLogger.notice("RecordingClient cleaned up")
}
}
extension DependencyValues {
var recording: RecordingClient {
get { self[RecordingClient.self] }
set { self[RecordingClient.self] = newValue }
}
}