Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ Open Settings from the menu bar popover or with **⌘,**
- **Max recording duration** - 30s, 60s, 120s, or 300s
- **Silence detection** - Auto-stop recording after configurable silence
- **Sound effects** - Toggle audio feedback for recording start/stop
- **Mute system audio while recording** - Temporarily mute the default output and restore its previous state when recording ends
- **Input device** - Select which microphone to use

### Models
Expand Down
23 changes: 21 additions & 2 deletions Sources/VocaMac/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ final class AppState: ObservableObject {
@AppStorage("vocamac.launchAtLogin") var launchAtLogin: Bool = false
@AppStorage("vocamac.preserveClipboard") var preserveClipboard: Bool = true
@AppStorage("vocamac.soundEffectsEnabled") var soundEffectsEnabled: Bool = true
@AppStorage("vocamac.muteSystemAudioWhileRecording") var muteSystemAudioWhileRecording: Bool = false
@AppStorage("vocamac.overlayStyle") var overlayStyle: OverlayStyle = .minimal
@AppStorage("vocamac.overlayPosition") var overlayPosition: OverlayPosition = .bottom
/// Legacy preference retained so existing installs that disabled the old
Expand Down Expand Up @@ -177,6 +178,7 @@ final class AppState: ObservableObject {
let hotKeyManager: HotKeyMonitoring
let modelManager: ModelManaging
let soundManager: SoundPlaying
let systemAudioMuter: SystemAudioMuting
let cursorOverlay: CursorOverlayManaging
let statsManager: StatsManaging
let updateChecker = UpdateChecker()
Expand Down Expand Up @@ -257,6 +259,7 @@ final class AppState: ObservableObject {
hotKeyManager: HotKeyMonitoring = HotKeyManager(),
modelManager: ModelManaging = ModelManager(),
soundManager: SoundPlaying = SoundManager(),
systemAudioMuter: SystemAudioMuting = SystemAudioMuteManager(),
cursorOverlay: CursorOverlayManaging,
statsManager: StatsManaging,
permissionManager: (any PermissionManaging)? = nil,
Expand All @@ -268,6 +271,7 @@ final class AppState: ObservableObject {
self.hotKeyManager = hotKeyManager
self.modelManager = modelManager
self.soundManager = soundManager
self.systemAudioMuter = systemAudioMuter
self.cursorOverlay = cursorOverlay
self.statsManager = statsManager
self.permissionManager = permissionManager ?? PermissionManager(audioEngine: audioEngine, hotKeyManager: hotKeyManager)
Expand Down Expand Up @@ -449,6 +453,7 @@ final class AppState: ObservableObject {
Task { @MainActor in
guard let self = self else { return }
VocaLogger.warning(.appState, "Audio device changed — recovering from interrupted recording")
self.systemAudioMuter.restoreSystemAudio()
self.isRecording = false
self.audioLevel = 0.0
self.cursorOverlay.hide()
Expand Down Expand Up @@ -788,6 +793,7 @@ final class AppState: ObservableObject {

// Reset audio engine unconditionally
audioEngine.forceReset()
systemAudioMuter.restoreSystemAudio()

// Reset hotkey tracking state
hotKeyManager.resetKeyState()
Expand Down Expand Up @@ -869,6 +875,7 @@ final class AppState: ObservableObject {

guard didStartRecording else {
VocaLogger.warning(.appState, "Audio engine failed to start — resetting recording state")
systemAudioMuter.restoreSystemAudio()
isRecording = false
audioLevel = 0.0
cursorOverlay.hide()
Expand All @@ -877,6 +884,10 @@ final class AppState: ObservableObject {
return
}

if muteSystemAudioWhileRecording && isRecording && appStatus == .recording {
systemAudioMuter.muteSystemAudio()
}

// Play start sound after mic is active (fire-and-forget)
if soundEffectsEnabled && isRecording && appStatus == .recording {
soundManager.playStartSound()
Expand All @@ -887,9 +898,13 @@ final class AppState: ObservableObject {
// Accept stop if we're recording OR if the audio engine thinks
// it's recording (covers stuck-state recovery scenarios where
// isRecording and appStatus may be out of sync).
guard isRecording || appStatus == .recording else { return }
guard isRecording || appStatus == .recording else {
systemAudioMuter.restoreSystemAudio()
return
}

let audioData = await stopAudioEngine()
systemAudioMuter.restoreSystemAudio()
isRecording = false
audioLevel = 0.0

Expand Down Expand Up @@ -977,9 +992,13 @@ final class AppState: ObservableObject {
/// Cancels the active recording without sending its audio to a transcription
/// engine. This is used by the overlay's cancel button.
func cancelRecording() async {
guard isRecording || appStatus == .recording else { return }
guard isRecording || appStatus == .recording else {
systemAudioMuter.restoreSystemAudio()
return
}

_ = await stopAudioEngine()
systemAudioMuter.restoreSystemAudio()
isRecording = false
audioLevel = 0.0
cursorOverlay.hide()
Expand Down
1 change: 1 addition & 0 deletions Sources/VocaMac/Services/Logger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import os
enum LogCategory: String {
case appState = "AppState"
case audioEngine = "AudioEngine"
case systemAudio = "SystemAudio"
case whisperService = "WhisperService"
case parakeetService = "ParakeetService"
case appleSpeechService = "AppleSpeechService"
Expand Down
8 changes: 8 additions & 0 deletions Sources/VocaMac/Services/ServiceProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ protocol SoundPlaying: AnyObject {
func playStopSoundAsync() async
}

// MARK: - SystemAudioMuting

/// Controls the default output device's mute state for the duration of a recording.
protocol SystemAudioMuting: AnyObject {
func muteSystemAudio()
func restoreSystemAudio()
}

// MARK: - HotKeyMonitoring

protocol HotKeyMonitoring: AnyObject {
Expand Down
129 changes: 129 additions & 0 deletions Sources/VocaMac/Services/SystemAudioMuteManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// SystemAudioMuteManager.swift
// VocaMac
//
// Temporarily mutes the macOS default output device while recording.

import Foundation
import CoreAudio

/// Temporarily mutes the default output device and restores its original state.
///
/// The snapshot is kept for one recording session so an output that was already
/// muted remains muted after recording, and a user volume/mute change is not
/// replaced with an assumed default state.
final class SystemAudioMuteManager: SystemAudioMuting {

private struct MuteSnapshot {
let deviceID: AudioDeviceID
let wasMuted: Bool
}

private let stateLock = NSLock()
private var muteSnapshot: MuteSnapshot?

deinit {
restoreSystemAudio()
}

/// Mutes the current default output device once for the active recording.
func muteSystemAudio() {
stateLock.lock()
defer { stateLock.unlock() }

guard muteSnapshot == nil else { return }
guard let deviceID = Self.defaultOutputDeviceID() else {
VocaLogger.warning(.systemAudio, "Unable to mute system audio: no default output device")
return
}
guard let wasMuted = Self.muteState(for: deviceID) else {
VocaLogger.warning(.systemAudio, "Unable to read mute state for default output device \(deviceID)")
return
}

if !wasMuted && !Self.setMute(true, for: deviceID) {
VocaLogger.warning(.systemAudio, "Unable to mute default output device \(deviceID)")
return
}

muteSnapshot = MuteSnapshot(deviceID: deviceID, wasMuted: wasMuted)
VocaLogger.debug(.systemAudio, "System audio muted for recording")
}

/// Restores the output device's mute state captured at recording start.
func restoreSystemAudio() {
stateLock.lock()
defer { stateLock.unlock() }

guard let snapshot = muteSnapshot else { return }
muteSnapshot = nil

guard Self.setMute(snapshot.wasMuted, for: snapshot.deviceID) else {
VocaLogger.warning(.systemAudio, "Unable to restore system audio mute state")
return
}
VocaLogger.debug(.systemAudio, "System audio mute state restored")
}

private static func defaultOutputDeviceID() -> AudioDeviceID? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var deviceID = AudioDeviceID(kAudioObjectUnknown)
var dataSize = UInt32(MemoryLayout<AudioDeviceID>.size)
let status = AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject),
&address,
0,
nil,
&dataSize,
&deviceID
)

guard status == noErr, deviceID != AudioDeviceID(kAudioObjectUnknown) else {
return nil
}
return deviceID
}

private static func muteState(for deviceID: AudioDeviceID) -> Bool? {
var address = mutePropertyAddress
var muteValue = UInt32(0)
var dataSize = UInt32(MemoryLayout<UInt32>.size)
let status = AudioObjectGetPropertyData(
deviceID,
&address,
0,
nil,
&dataSize,
&muteValue
)

guard status == noErr else { return nil }
return muteValue != 0
}

private static func setMute(_ muted: Bool, for deviceID: AudioDeviceID) -> Bool {
var address = mutePropertyAddress
var muteValue = muted ? UInt32(1) : UInt32(0)
let dataSize = UInt32(MemoryLayout<UInt32>.size)
let status = AudioObjectSetPropertyData(
deviceID,
&address,
0,
nil,
dataSize,
&muteValue
)
return status == noErr
}

private static var mutePropertyAddress: AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyMute,
mScope: kAudioObjectPropertyScopeOutput,
mElement: kAudioObjectPropertyElementMain
)
}
}
8 changes: 8 additions & 0 deletions Sources/VocaMac/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,14 @@ struct AudioSettingsTab: View {
.foregroundStyle(.secondary)
}

Section("System Audio") {
Toggle("Mute system audio while recording", isOn: $appState.muteSystemAudioWhileRecording)

Text("Temporarily mutes the default output device while VocaMac records, then restores its previous mute state.")
.font(.caption)
.foregroundStyle(.secondary)
}

Section("Input Device") {
Picker("Microphone", selection: $appState.selectedAudioDeviceID) {
Text("System Default").tag("")
Expand Down
48 changes: 48 additions & 0 deletions Tests/VocaMacTests/AppStateRecordingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,52 @@ final class AppStateRecordingTests: XCTestCase {
"Error message should mention microphone")
}

func testMuteSystemAudioWhileRecordingRestoresAfterStop() async {
let (appState, mocks) = AppState.makeTestState()
appState.muteSystemAudioWhileRecording = true

await appState.startRecording()

XCTAssertEqual(mocks.systemAudioMuter.muteCallCount, 1)

await appState.stopRecordingAndTranscribe()

XCTAssertEqual(mocks.systemAudioMuter.restoreCallCount, 1)
}

func testMuteSystemAudioWhileRecordingRestoresAfterCancel() async {
let (appState, mocks) = AppState.makeTestState()
appState.muteSystemAudioWhileRecording = true

await appState.startRecording()
await appState.cancelRecording()

XCTAssertEqual(mocks.systemAudioMuter.muteCallCount, 1)
XCTAssertEqual(mocks.systemAudioMuter.restoreCallCount, 1)
}

func testMuteSystemAudioWhileRecordingRestoresAfterForceRecovery() async {
let (appState, mocks) = AppState.makeTestState()
appState.muteSystemAudioWhileRecording = true

await appState.startRecording()
appState.forceRecovery()

XCTAssertEqual(mocks.systemAudioMuter.muteCallCount, 1)
XCTAssertEqual(mocks.systemAudioMuter.restoreCallCount, 1)
}

func testMuteSystemAudioIsNotChangedWhenDisabled() async {
let (appState, mocks) = AppState.makeTestState()

await appState.startRecording()
await appState.stopRecordingAndTranscribe()

XCTAssertEqual(mocks.systemAudioMuter.muteCallCount, 0)
XCTAssertEqual(mocks.systemAudioMuter.restoreCallCount, 1,
"Restoring is idempotent and protects recovery paths")
}

func testStartRecordingPassesOverlayStyleAndPosition() async {
let (appState, mocks) = AppState.makeTestState()
let originalStyle = appState.overlayStyle
Expand Down Expand Up @@ -575,6 +621,8 @@ final class AppStateRecordingGuardTests: XCTestCase {
"failed audio start should reset hotkey state")
XCTAssertEqual(mocks.soundManager.startSoundCallCount, 0,
"failed audio start should not play the start sound")
XCTAssertEqual(mocks.systemAudioMuter.muteCallCount, 0,
"System audio should not be muted when microphone startup fails")
}

@MainActor
Expand Down
20 changes: 20 additions & 0 deletions Tests/VocaMacTests/Mocks/MockServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ final class MockSoundManager: SoundPlaying {
}
}

// MARK: - MockSystemAudioMuter

final class MockSystemAudioMuter: SystemAudioMuting {
var muteCallCount = 0
var restoreCallCount = 0

func muteSystemAudio() {
muteCallCount += 1
}

func restoreSystemAudio() {
restoreCallCount += 1
}
}

// MARK: - MockHotKeyManager

final class MockHotKeyManager: HotKeyMonitoring {
Expand Down Expand Up @@ -484,9 +499,11 @@ extension AppState {
) -> (appState: AppState, mocks: TestMocks) {
UserDefaults.standard.removeObject(forKey: "vocamac.selectedAudioDeviceID")
UserDefaults.standard.removeObject(forKey: "vocamac.selectedAudioDeviceName")
UserDefaults.standard.removeObject(forKey: "vocamac.muteSystemAudioWhileRecording")

let audioEngine = MockAudioEngine()
let soundManager = MockSoundManager()
let systemAudioMuter = MockSystemAudioMuter()
let hotKeyManager = MockHotKeyManager()
let permissionManager = MockPermissionManager()
let cursorOverlay = MockCursorOverlay()
Expand All @@ -496,6 +513,7 @@ extension AppState {
let mocks = TestMocks(
audioEngine: audioEngine,
soundManager: soundManager,
systemAudioMuter: systemAudioMuter,
hotKeyManager: hotKeyManager,
permissionManager: permissionManager,
cursorOverlay: cursorOverlay,
Expand All @@ -511,6 +529,7 @@ extension AppState {
hotKeyManager: hotKeyManager,
modelManager: modelManager,
soundManager: soundManager,
systemAudioMuter: systemAudioMuter,
cursorOverlay: cursorOverlay,
statsManager: statsManager,
permissionManager: permissionManager,
Expand All @@ -523,6 +542,7 @@ extension AppState {
struct TestMocks {
let audioEngine: MockAudioEngine
let soundManager: MockSoundManager
let systemAudioMuter: MockSystemAudioMuter
let hotKeyManager: MockHotKeyManager
let permissionManager: MockPermissionManager
let cursorOverlay: MockCursorOverlay
Expand Down
Loading
Loading