diff --git a/.changeset/e8998ed7.md b/.changeset/e8998ed7.md new file mode 100644 index 000000000..13c5b7752 --- /dev/null +++ b/.changeset/e8998ed7.md @@ -0,0 +1,5 @@ +--- +"hex-app": minor +--- + +Add recording volume reduction with fade controls and preserve restore across overlapping recordings (#220) diff --git a/Hex/Clients/RecordingClient.swift b/Hex/Clients/RecordingClient.swift index d1e62fdcb..5890c9419 100644 --- a/Hex/Clients/RecordingClient.swift +++ b/Hex/Clients/RecordingClient.swift @@ -16,6 +16,8 @@ import HexCore private let recordingLogger = HexLog.recording private let mediaLogger = HexLog.media +// Ignore tiny differences from Core Audio rounding while still detecting normal volume-key steps. +private let volumeAdjustmentThreshold: Float = 0.025 private typealias CoreAudioPropertyListenerBlock = @convention(block) (UInt32, UnsafePointer) -> Void /// Represents an audio input device @@ -337,6 +339,9 @@ actor RecordingClientLive { private var deferredCaptureRestartReason: String? private var environmentChangeDebounceTask: Task? private var mediaControlTask: Task? + private var volumeFadeTask: Task? + private var volumeMonitorTask: Task? + private var volumeControlGeneration: UInt64 = 0 private let recorderSettings: [String: Any] = [ AVFormatIDKey: Int(kAudioFormatLinearPCM), AVSampleRateKey: 16000.0, @@ -372,8 +377,10 @@ actor RecordingClientLive { /// Tracks which specific media players were paused private var pausedPlayers: [String] = [] - /// Tracks previous system volume when muted for recording + /// Tracks previous system volume when volume is changed for recording private var previousVolume: Float? + /// Tracks the last output volume Hex intentionally applied during recording volume control. + private var lastAppliedRecordingVolume: Float? // Cache to store already-processed device information private var deviceCache: [AudioDeviceID: (hasInput: Bool, name: String?)] = [:] @@ -960,18 +967,267 @@ actor RecordingClientLive { // MARK: - Volume Control - /// Mutes system volume and returns the previous volume level - private func muteSystemVolume() async -> Float { + /// Mutes system volume and stores the previous volume for the active session. + private func muteSystemVolume(sessionID: UUID) { + guard recordingSessionID == sessionID else { return } + let didStart = beginVolumeRamp(to: 0, fadeDuration: 0, sessionID: sessionID) + if didStart { + recordingLogger.notice("Muted system volume") + } + } + + /// Lowers system volume to the user's target and stores the previous volume for the active session. + private func reduceSystemVolume(to targetVolume: Double, fadeDuration: Double, sessionID: UUID) { + guard recordingSessionID == sessionID else { return } + let clampedTarget = Float(HexSettings.clampVolume(targetVolume)) let currentVolume = getSystemVolume() - setSystemVolume(0) - recordingLogger.notice("Muted system volume (was \(String(format: "%.2f", currentVolume)))") - return currentVolume + guard currentVolume > clampedTarget + 0.005 else { + if adoptCurrentVolumeForRecording(sessionID: sessionID) { + mediaLogger.notice( + "Keeping recording volume duck at \(String(format: "%.2f", currentVolume)); target is \(String(format: "%.2f", clampedTarget))" + ) + return + } + mediaLogger.notice( + "Keeping system volume at \(String(format: "%.2f", currentVolume)); target is \(String(format: "%.2f", clampedTarget))" + ) + return + } + + guard beginVolumeRamp(to: clampedTarget, fadeDuration: fadeDuration, sessionID: sessionID) else { return } + mediaLogger.notice( + "Reduced system volume to \(String(format: "%.2f", clampedTarget)) (was \(String(format: "%.2f", currentVolume)))" + ) + } + + private func adoptCurrentVolumeForRecording(sessionID: UUID) -> Bool { + guard recordingSessionID == sessionID, previousVolume != nil else { + return false + } + + volumeFadeTask?.cancel() + volumeFadeTask = nil + let generation = advanceVolumeControlGeneration() + lastAppliedRecordingVolume = getSystemVolume() + startRecordingVolumeMonitor(sessionID: sessionID, generation: generation) + return true } /// 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))") + private func restoreSystemVolume(_ volume: Float, fadeDuration: Double) { + stopRecordingVolumeMonitor() + let currentVolume = getSystemVolume() + let rampGeneration = startVolumeRamp( + from: currentVolume, + to: volume, + duration: fadeDuration, + sessionID: nil, + clearRestoreVolume: volume + ) + if rampGeneration != nil { + recordingLogger.notice("Restoring system volume to \(String(format: "%.2f", volume))") + } else { + clearVolumeRestoreState(expectedVolume: volume) + } + } + + @discardableResult + private func beginVolumeRamp(to targetVolume: Float, fadeDuration: Double, sessionID: UUID) -> Bool { + let currentVolume = getSystemVolume() + let hadPreviousVolume = previousVolume != nil + if previousVolume == nil { + previousVolume = currentVolume + } + // Preserve the original restore target if a double-tap lock session overlaps + // a prior stop grace period while volume is already ducked. (#220) + lastAppliedRecordingVolume = currentVolume + let rampGeneration = startVolumeRamp( + from: currentVolume, + to: targetVolume, + duration: fadeDuration, + sessionID: sessionID, + clearRestoreVolume: nil + ) + if rampGeneration == nil, !hadPreviousVolume { + previousVolume = nil + lastAppliedRecordingVolume = nil + } + if let rampGeneration { + startRecordingVolumeMonitor(sessionID: sessionID, generation: rampGeneration) + } + return rampGeneration != nil + } + + private func startRecordingVolumeMonitor(sessionID: UUID, generation: UInt64) { + volumeMonitorTask?.cancel() + volumeMonitorTask = Task { + await self.monitorRecordingVolume(sessionID: sessionID, generation: generation) + } + } + + private func stopRecordingVolumeMonitor() { + volumeMonitorTask?.cancel() + volumeMonitorTask = nil + } + + private func monitorRecordingVolume(sessionID: UUID, generation: UInt64) async { + try? await Task.sleep(for: .milliseconds(150)) + + while !Task.isCancelled { + guard isCurrentVolumeControl(generation: generation, sessionID: sessionID) else { return } + guard previousVolume != nil, let expectedVolume = lastAppliedRecordingVolume else { return } + + let currentVolume = getSystemVolume() + if abs(currentVolume - expectedVolume) > volumeAdjustmentThreshold { + releaseRecordingVolumeDuck( + manualVolume: currentVolume, + expectedVolume: expectedVolume, + sessionID: sessionID, + generation: generation + ) + return + } + + try? await Task.sleep(for: .milliseconds(75)) + } + } + + private func releaseRecordingVolumeDuck( + manualVolume: Float, + expectedVolume: Float, + sessionID: UUID, + generation: UInt64 + ) { + guard isCurrentVolumeControl(generation: generation, sessionID: sessionID) else { return } + + volumeFadeTask?.cancel() + volumeFadeTask = nil + stopRecordingVolumeMonitor() + advanceVolumeControlGeneration() + + previousVolume = nil + lastAppliedRecordingVolume = nil + + mediaLogger.notice( + "Released recording volume duck after manual volume change; current=\(String(format: "%.2f", manualVolume)) expected=\(String(format: "%.2f", expectedVolume))" + ) + } + + private func hasManualVolumeAdjustment() -> Bool { + let currentVolume = getSystemVolume() + guard let lastAppliedRecordingVolume else { + return false + } + + let adjustment = currentVolume - lastAppliedRecordingVolume + return abs(adjustment) > volumeAdjustmentThreshold + } + + private func startVolumeRamp( + from startVolume: Float, + to targetVolume: Float, + duration: Double, + sessionID: UUID?, + clearRestoreVolume: Float? + ) -> UInt64? { + volumeFadeTask?.cancel() + volumeFadeTask = nil + let generation = advanceVolumeControlGeneration() + + let clampedTarget = min(1, max(0, targetVolume)) + let clampedDuration = HexSettings.clampFadeDuration(duration) + guard clampedDuration > 0 else { + let didSetVolume = setSystemVolumeAndTrack(clampedTarget) + if didSetVolume, let clearRestoreVolume { + finishVolumeRestore(expectedVolume: clearRestoreVolume, generation: generation) + } + return didSetVolume ? generation : nil + } + + volumeFadeTask = Task { + await self.fadeSystemVolume( + from: startVolume, + to: clampedTarget, + duration: clampedDuration, + sessionID: sessionID, + clearRestoreVolume: clearRestoreVolume, + generation: generation + ) + } + return generation + } + + private func fadeSystemVolume( + from startVolume: Float, + to targetVolume: Float, + duration: Double, + sessionID: UUID?, + clearRestoreVolume: Float?, + generation: UInt64 + ) async { + let stepInterval = 0.025 + let stepCount = max(1, Int(ceil(duration / stepInterval))) + + for step in 1...stepCount { + if Task.isCancelled { return } + guard isCurrentVolumeControl(generation: generation, sessionID: sessionID) else { return } + + let progress = Float(step) / Float(stepCount) + let volume = startVolume + ((targetVolume - startVolume) * progress) + guard setSystemVolumeAndTrack(volume) else { + if let clearRestoreVolume { + finishVolumeRestore(expectedVolume: clearRestoreVolume, generation: generation) + } + return + } + + if step < stepCount { + try? await Task.sleep(for: .milliseconds(Int((stepInterval * 1000).rounded()))) + } + } + + if let clearRestoreVolume { + finishVolumeRestore(expectedVolume: clearRestoreVolume, generation: generation) + } else if volumeControlGeneration == generation { + volumeFadeTask = nil + } + } + + private func finishVolumeRestore(expectedVolume: Float, generation: UInt64) { + guard volumeControlGeneration == generation else { return } + if previousVolume == expectedVolume { + previousVolume = nil + } + lastAppliedRecordingVolume = nil + volumeFadeTask = nil + } + + private func clearVolumeRestoreState(expectedVolume: Float) { + if previousVolume == expectedVolume { + previousVolume = nil + } + lastAppliedRecordingVolume = nil + volumeFadeTask = nil + } + + @discardableResult + private func advanceVolumeControlGeneration() -> UInt64 { + volumeControlGeneration &+= 1 + return volumeControlGeneration + } + + private func isCurrentVolumeControl(generation: UInt64, sessionID: UUID?) -> Bool { + guard volumeControlGeneration == generation else { return false } + if let sessionID { + return recordingSessionID == sessionID + } + return true + } + + private func setSystemVolumeAndTrack(_ volume: Float) -> Bool { + guard setSystemVolume(volume) else { return false } + lastAppliedRecordingVolume = getSystemVolume() + return true } /// Gets the default output device ID @@ -1025,12 +1281,13 @@ actor RecordingClientLive { } /// Sets the system output volume (0.0 to 1.0) - private func setSystemVolume(_ volume: Float) { + @discardableResult + private func setSystemVolume(_ volume: Float) -> Bool { guard let deviceID = getDefaultOutputDevice() else { - return + return false } - var newVolume = volume + var newVolume = min(1, max(0, volume)) let size = UInt32(MemoryLayout.size) var address = audioPropertyAddress(kAudioHardwareServiceDeviceProperty_VirtualMainVolume, scope: kAudioDevicePropertyScopeOutput) @@ -1045,7 +1302,9 @@ actor RecordingClientLive { if status != 0 { recordingLogger.error("Failed to set system volume: \(status)") + return false } + return true } func startRecording() async { @@ -1088,12 +1347,12 @@ actor RecordingClientLive { } case .mute: - // Mute system volume in background - mediaControlTask = Task { [sessionID] in - guard await self.isCurrentSession(sessionID) else { return } - let volume = await self.muteSystemVolume() - await self.setPreviousVolume(volume, sessionID: sessionID) - } + muteSystemVolume(sessionID: sessionID) + + case .reduceVolume: + let targetVolume = hexSettings.recordingReducedVolume + let fadeOutDuration = hexSettings.recordingVolumeFadeOutDuration + reduceSystemVolume(to: targetVolume, fadeDuration: fadeOutDuration, sessionID: sessionID) case .doNothing: // No audio handling @@ -1129,7 +1388,7 @@ actor RecordingClientLive { let recordCallStartedAt = Date() guard recorder.record() else { recordingLogger.error("AVAudioRecorder refused to start recording") - endRecordingSession() + await abortRecordingStart() return } let startedAt = Date() @@ -1144,11 +1403,16 @@ actor RecordingClientLive { ) } catch { recordingLogger.error("Failed to start recording: \(error.localizedDescription)") - clearActiveRecordingMetadata() - endRecordingSession() + await abortRecordingStart() } } + private func abortRecordingStart() async { + clearActiveRecordingMetadata() + endRecordingSession() + await resumeMediaIfNeeded() + } + func stopRecording() async -> URL { let stopSessionID = recordingSessionID let activeSession = activeRecordingSession @@ -1251,15 +1515,26 @@ actor RecordingClientLive { let shouldResumeMedia = didPauseMedia let shouldResumeViaMediaRemote = didPauseViaMediaRemote let volumeToRestore = previousVolume + let volumeFadeInDuration = hexSettings.recordingVolumeFadeInDuration + + if let volume = volumeToRestore { + if hasManualVolumeAdjustment() { + stopRecordingVolumeMonitor() + volumeFadeTask?.cancel() + volumeFadeTask = nil + advanceVolumeControlGeneration() + previousVolume = nil + lastAppliedRecordingVolume = nil + mediaLogger.notice("Skipped recording volume restore after manual volume change") + } else { + restoreSystemVolume(volume, fadeDuration: volumeFadeInDuration) + } + } if !playersToResume.isEmpty || shouldResumeMedia || shouldResumeViaMediaRemote || volumeToRestore != nil { Task { - // Restore volume if it was muted - if let volume = volumeToRestore { - await self.restoreSystemVolume(volume) - } // Resume media if we previously paused specific players - else if !playersToResume.isEmpty { + if !playersToResume.isEmpty { mediaLogger.notice("Resuming players: \(playersToResume.joined(separator: ", "))") await resumeMediaApplications(playersToResume) } @@ -1322,16 +1597,10 @@ actor RecordingClientLive { 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 @@ -1463,6 +1732,23 @@ actor RecordingClientLive { /// Release recorder resources. Call on app termination. func cleanup() { endRecordingSession() + stopRecordingVolumeMonitor() + volumeFadeTask?.cancel() + volumeFadeTask = nil + if let volume = previousVolume { + if hasManualVolumeAdjustment() { + previousVolume = nil + lastAppliedRecordingVolume = nil + recordingLogger.notice("Skipped volume restore during cleanup after manual volume change") + } else if setSystemVolume(volume) { + previousVolume = nil + lastAppliedRecordingVolume = nil + recordingLogger.notice("Restored system volume during recording cleanup") + } else { + recordingLogger.error("Failed to restore system volume during recording cleanup") + } + } + clearMediaState() stopObservingSystemChanges() stopCaptureController(reason: "cleanup") releaseRecorder(reason: "cleanup") diff --git a/Hex/Features/Settings/GeneralSectionView.swift b/Hex/Features/Settings/GeneralSectionView.swift index 66b6459fd..b13c62536 100644 --- a/Hex/Features/Settings/GeneralSectionView.swift +++ b/Hex/Features/Settings/GeneralSectionView.swift @@ -83,21 +83,90 @@ struct GeneralSectionView: View { } Label { - HStack(alignment: .center) { - Text("Audio Behavior while Recording") - Spacer() - Picker("", selection: Binding( - get: { store.hexSettings.recordingAudioBehavior }, - set: { store.send(.setRecordingAudioBehavior($0)) } - )) { - Label("Pause Media", systemImage: "pause") - .tag(RecordingAudioBehavior.pauseMedia) - Label("Mute Volume", systemImage: "speaker.slash") - .tag(RecordingAudioBehavior.mute) - Label("Do Nothing", systemImage: "hand.raised.slash") - .tag(RecordingAudioBehavior.doNothing) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center) { + Text("Audio Behavior while Recording") + Spacer() + Picker("", selection: Binding( + get: { store.hexSettings.recordingAudioBehavior }, + set: { store.send(.setRecordingAudioBehavior($0)) } + )) { + Label("Pause Media", systemImage: "pause") + .tag(RecordingAudioBehavior.pauseMedia) + Label("Mute Volume", systemImage: "speaker.slash") + .tag(RecordingAudioBehavior.mute) + Label("Reduce Volume", systemImage: "speaker.wave.1") + .tag(RecordingAudioBehavior.reduceVolume) + Label("Do Nothing", systemImage: "hand.raised.slash") + .tag(RecordingAudioBehavior.doNothing) + } + .pickerStyle(.menu) + } + + if store.hexSettings.recordingAudioBehavior == .reduceVolume { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text("Reduced Volume") + Text("Playback level while recording") + .recordingVolumeDescriptionStyle() + } + Spacer() + Text(formattedRecordingVolume(store.hexSettings.recordingReducedVolume)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: Binding( + get: { store.hexSettings.recordingReducedVolume }, + set: { store.send(.setRecordingReducedVolume($0)) } + ), + in: 0...1, + step: 0.05 + ) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text("Fade Out") + Text("At recording start") + .recordingVolumeDescriptionStyle() + } + Spacer() + Text(formattedFadeDuration(store.hexSettings.recordingVolumeFadeOutDuration)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: Binding( + get: { store.hexSettings.recordingVolumeFadeOutDuration }, + set: { store.send(.setRecordingVolumeFadeOutDuration($0)) } + ), + in: 0...HexSettings.maximumRecordingVolumeFadeDuration, + step: 0.05 + ) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text("Fade In") + Text("After recording ends") + .recordingVolumeDescriptionStyle() + } + Spacer() + Text(formattedFadeDuration(store.hexSettings.recordingVolumeFadeInDuration)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: Binding( + get: { store.hexSettings.recordingVolumeFadeInDuration }, + set: { store.send(.setRecordingVolumeFadeInDuration($0)) } + ), + in: 0...HexSettings.maximumRecordingVolumeFadeDuration, + step: 0.05 + ) + } + .padding(.top, 10) } - .pickerStyle(.menu) } } icon: { Image(systemName: "speaker.wave.2") @@ -108,3 +177,21 @@ struct GeneralSectionView: View { .enableInjection() } } + +private func formattedRecordingVolume(_ volume: Double) -> String { + let clampedVolume = HexSettings.clampVolume(volume) + return "\(Int(round(clampedVolume * 100)))%" +} + +private func formattedFadeDuration(_ duration: Double) -> String { + let clampedDuration = HexSettings.clampFadeDuration(duration) + return String(format: "%.2fs", clampedDuration) +} + +private extension Text { + func recordingVolumeDescriptionStyle() -> some View { + self + .font(.subheadline) + .foregroundStyle(.secondary) + } +} diff --git a/Hex/Features/Settings/SettingsFeature.swift b/Hex/Features/Settings/SettingsFeature.swift index b633cc318..5e8a25651 100644 --- a/Hex/Features/Settings/SettingsFeature.swift +++ b/Hex/Features/Settings/SettingsFeature.swift @@ -74,6 +74,9 @@ struct SettingsFeature { case toggleShowDockIcon(Bool) case togglePreventSystemSleep(Bool) case setRecordingAudioBehavior(RecordingAudioBehavior) + case setRecordingReducedVolume(Double) + case setRecordingVolumeFadeOutDuration(Double) + case setRecordingVolumeFadeInDuration(Double) case toggleSuperFastMode(Bool) case setUseClipboardPaste(Bool) case setCopyToClipboard(Bool) @@ -465,6 +468,18 @@ struct SettingsFeature { state.$hexSettings.withLock { $0.recordingAudioBehavior = behavior } return .none + case let .setRecordingReducedVolume(volume): + state.$hexSettings.withLock { $0.recordingReducedVolume = HexSettings.clampVolume(volume) } + return .none + + case let .setRecordingVolumeFadeOutDuration(duration): + state.$hexSettings.withLock { $0.recordingVolumeFadeOutDuration = HexSettings.clampFadeDuration(duration) } + return .none + + case let .setRecordingVolumeFadeInDuration(duration): + state.$hexSettings.withLock { $0.recordingVolumeFadeInDuration = HexSettings.clampFadeDuration(duration) } + return .none + case let .toggleSuperFastMode(enabled): state.$hexSettings.withLock { $0.superFastModeEnabled = enabled } return .run { _ in diff --git a/Hex/Features/Transcription/TranscriptionFeature.swift b/Hex/Features/Transcription/TranscriptionFeature.swift index e22b74782..0c8ff8ba8 100644 --- a/Hex/Features/Transcription/TranscriptionFeature.swift +++ b/Hex/Features/Transcription/TranscriptionFeature.swift @@ -264,9 +264,13 @@ private extension TranscriptionFeature { private extension TranscriptionFeature { func handleHotKeyPressed(isTranscribing: Bool) -> Effect { // If already transcribing, cancel first. Otherwise start recording immediately. - let maybeCancel = isTranscribing ? Effect.send(Action.cancel) : .none - let startRecording = Effect.send(Action.startRecording) - return .merge(maybeCancel, startRecording) + if isTranscribing { + return .concatenate( + .send(.cancel), + .send(.startRecording) + ) + } + return .send(.startRecording) } func handleHotKeyReleased(isRecording: Bool) -> Effect { diff --git a/HexCore/Sources/HexCore/Settings/HexSettings.swift b/HexCore/Sources/HexCore/Settings/HexSettings.swift index 4c301ad95..c4b9584f8 100644 --- a/HexCore/Sources/HexCore/Settings/HexSettings.swift +++ b/HexCore/Sources/HexCore/Settings/HexSettings.swift @@ -3,6 +3,7 @@ import Foundation public enum RecordingAudioBehavior: String, Codable, CaseIterable, Equatable, Sendable { case pauseMedia case mute + case reduceVolume case doNothing } @@ -10,6 +11,9 @@ public enum RecordingAudioBehavior: String, Codable, CaseIterable, Equatable, Se public struct HexSettings: Codable, Equatable, Sendable { public static let defaultPasteLastTranscriptHotkey = HotKey(key: .v, modifiers: [.option, .shift]) public static let baseSoundEffectsVolume: Double = HexCoreConstants.baseSoundEffectsVolume + public static let defaultRecordingReducedVolume: Double = 0.2 + public static let defaultRecordingVolumeFadeDuration: Double = 0 + public static let maximumRecordingVolumeFadeDuration: Double = 2 public static let defaultWordRemovals: [WordRemoval] = [ .init(pattern: "uh+"), .init(pattern: "um+"), @@ -32,6 +36,9 @@ public struct HexSettings: Codable, Equatable, Sendable { public var useClipboardPaste: Bool public var preventSystemSleep: Bool public var recordingAudioBehavior: RecordingAudioBehavior + public var recordingReducedVolume: Double + public var recordingVolumeFadeOutDuration: Double + public var recordingVolumeFadeInDuration: Double public var minimumKeyTime: Double public var copyToClipboard: Bool public var superFastModeEnabled: Bool @@ -54,6 +61,20 @@ public struct HexSettings: Codable, Equatable, Sendable { } } + private mutating func normalizeRecordingAudioSettings() { + recordingReducedVolume = Self.clampVolume(recordingReducedVolume) + recordingVolumeFadeOutDuration = Self.clampFadeDuration(recordingVolumeFadeOutDuration) + recordingVolumeFadeInDuration = Self.clampFadeDuration(recordingVolumeFadeInDuration) + } + + public static func clampVolume(_ volume: Double) -> Double { + min(1, max(0, volume)) + } + + public static func clampFadeDuration(_ duration: Double) -> Double { + min(maximumRecordingVolumeFadeDuration, max(0, duration)) + } + public init( soundEffectsEnabled: Bool = true, soundEffectsVolume: Double = HexSettings.baseSoundEffectsVolume, @@ -64,6 +85,9 @@ public struct HexSettings: Codable, Equatable, Sendable { useClipboardPaste: Bool = true, preventSystemSleep: Bool = true, recordingAudioBehavior: RecordingAudioBehavior = .doNothing, + recordingReducedVolume: Double = HexSettings.defaultRecordingReducedVolume, + recordingVolumeFadeOutDuration: Double = HexSettings.defaultRecordingVolumeFadeDuration, + recordingVolumeFadeInDuration: Double = HexSettings.defaultRecordingVolumeFadeDuration, minimumKeyTime: Double = HexCoreConstants.defaultMinimumKeyTime, copyToClipboard: Bool = false, superFastModeEnabled: Bool = true, @@ -89,6 +113,9 @@ public struct HexSettings: Codable, Equatable, Sendable { self.useClipboardPaste = useClipboardPaste self.preventSystemSleep = preventSystemSleep self.recordingAudioBehavior = recordingAudioBehavior + self.recordingReducedVolume = recordingReducedVolume + self.recordingVolumeFadeOutDuration = recordingVolumeFadeOutDuration + self.recordingVolumeFadeInDuration = recordingVolumeFadeInDuration self.minimumKeyTime = minimumKeyTime self.copyToClipboard = copyToClipboard self.superFastModeEnabled = superFastModeEnabled @@ -105,6 +132,7 @@ public struct HexSettings: Codable, Equatable, Sendable { self.wordRemovals = wordRemovals self.wordRemappings = wordRemappings normalizeDoubleTapSettings() + normalizeRecordingAudioSettings() } public init(from decoder: Decoder) throws { @@ -114,6 +142,7 @@ public struct HexSettings: Codable, Equatable, Sendable { try field.decode(into: &self, from: container) } normalizeDoubleTapSettings() + normalizeRecordingAudioSettings() } public func encode(to encoder: Encoder) throws { @@ -136,6 +165,9 @@ private enum HexSettingKey: String, CodingKey, CaseIterable { case useClipboardPaste case preventSystemSleep case recordingAudioBehavior + case recordingReducedVolume + case recordingVolumeFadeOutDuration + case recordingVolumeFadeInDuration case pauseMediaOnRecord // Legacy case minimumKeyTime case copyToClipboard @@ -234,6 +266,9 @@ private enum HexSettingsSchema { return defaultValue } ).eraseToAny(), + SettingsField(.recordingReducedVolume, keyPath: \.recordingReducedVolume, default: defaults.recordingReducedVolume).eraseToAny(), + SettingsField(.recordingVolumeFadeOutDuration, keyPath: \.recordingVolumeFadeOutDuration, default: defaults.recordingVolumeFadeOutDuration).eraseToAny(), + SettingsField(.recordingVolumeFadeInDuration, keyPath: \.recordingVolumeFadeInDuration, default: defaults.recordingVolumeFadeInDuration).eraseToAny(), SettingsField(.minimumKeyTime, keyPath: \.minimumKeyTime, default: defaults.minimumKeyTime).eraseToAny(), SettingsField(.copyToClipboard, keyPath: \.copyToClipboard, default: defaults.copyToClipboard).eraseToAny(), SettingsField(.superFastModeEnabled, keyPath: \.superFastModeEnabled, default: false).eraseToAny(), diff --git a/HexCore/Tests/HexCoreTests/HexSettingsMigrationTests.swift b/HexCore/Tests/HexCoreTests/HexSettingsMigrationTests.swift index bd0500aae..75ccac669 100644 --- a/HexCore/Tests/HexCoreTests/HexSettingsMigrationTests.swift +++ b/HexCore/Tests/HexCoreTests/HexSettingsMigrationTests.swift @@ -14,6 +14,9 @@ final class HexSettingsMigrationTests: XCTestCase { XCTAssertEqual(decoded.selectedModel, "whisper-large-v3") XCTAssertEqual(decoded.useClipboardPaste, false) XCTAssertEqual(decoded.preventSystemSleep, true) + XCTAssertEqual(decoded.recordingReducedVolume, HexSettings.defaultRecordingReducedVolume) + XCTAssertEqual(decoded.recordingVolumeFadeOutDuration, HexSettings.defaultRecordingVolumeFadeDuration) + XCTAssertEqual(decoded.recordingVolumeFadeInDuration, HexSettings.defaultRecordingVolumeFadeDuration) XCTAssertEqual(decoded.minimumKeyTime, 0.25) XCTAssertEqual(decoded.copyToClipboard, true) XCTAssertFalse(decoded.superFastModeEnabled) @@ -38,6 +41,40 @@ final class HexSettingsMigrationTests: XCTestCase { XCTAssertTrue(HexSettings().superFastModeEnabled) } + func testNewSettingsDefaultRecordingReducedVolume() { + XCTAssertEqual(HexSettings().recordingReducedVolume, 0.2) + } + + func testInitClampsRecordingReducedVolume() { + XCTAssertEqual(HexSettings(recordingReducedVolume: -1).recordingReducedVolume, 0) + XCTAssertEqual(HexSettings(recordingReducedVolume: 2).recordingReducedVolume, 1) + } + + func testInitClampsRecordingVolumeFadeDurations() { + let settings = HexSettings( + recordingVolumeFadeOutDuration: -1, + recordingVolumeFadeInDuration: 5 + ) + + XCTAssertEqual(settings.recordingVolumeFadeOutDuration, 0) + XCTAssertEqual(settings.recordingVolumeFadeInDuration, HexSettings.maximumRecordingVolumeFadeDuration) + } + + func testDecodeClampsRecordingReducedVolume() throws { + let payload = "{\"recordingAudioBehavior\":\"reduceVolume\",\"recordingReducedVolume\":1.5,\"recordingVolumeFadeOutDuration\":5,\"recordingVolumeFadeInDuration\":-1}" + guard let data = payload.data(using: .utf8) else { + XCTFail("Failed to encode JSON payload") + return + } + + let decoded = try JSONDecoder().decode(HexSettings.self, from: data) + + XCTAssertEqual(decoded.recordingAudioBehavior, .reduceVolume) + XCTAssertEqual(decoded.recordingReducedVolume, 1) + XCTAssertEqual(decoded.recordingVolumeFadeOutDuration, HexSettings.maximumRecordingVolumeFadeDuration) + XCTAssertEqual(decoded.recordingVolumeFadeInDuration, 0) + } + func testInitNormalizesDoubleTapOnlyWhenLockDisabled() { let settings = HexSettings(useDoubleTapOnly: true, doubleTapLockEnabled: false) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 958213793..2ab1c0dc8 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -83,6 +83,9 @@ }, "Audio Behavior while Recording" : { + }, + "After recording ends" : { + }, "Become a Sponsor" : { @@ -307,6 +310,15 @@ } } } + }, + "At recording start" : { + + }, + "Fade In" : { + + }, + "Fade Out" : { + }, "General" : { "comment" : "General section in Settings Header.", @@ -490,6 +502,9 @@ }, "Pause Media" : { + }, + "Playback level while recording" : { + }, "Pause Media while Recording" : { "comment" : "Label for general setting whether to pause media while recording.", @@ -541,6 +556,12 @@ }, "Quit" : { + }, + "Reduce Volume" : { + + }, + "Reduced Volume" : { + }, "Refresh available input devices" : { @@ -791,4 +812,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +}