Skip to content

Commit 9c6dc44

Browse files
HarshJa1nmxvsh
andauthored
feat: mute system audio during dictation (#3)
Adds opt-in setting to silence the default output device while a dictation session is active, so media playing in other apps cannot bleed into the microphone and corrupt transcriptions. Implementation: - SystemAudioDucker (Utilities/SystemAudioDucker.swift): reads and writes kAudioDevicePropertyMute on the current default output device via CoreAudio. Saves the device's pre-session mute state and restores it on stop, so the user's volume is never touched. Devices that don't support the mute property (some Bluetooth sinks) are silently ignored. - AppState: new muteSystemAudio Bool setting persisted in UserDefaults; duck() is called right before AVAudioRecorder starts (including restore in the error path) and restore() is called as soon as recording stops, before the transcription wait. - HomeView: "Mute system audio while dictating" toggle added to the Transcription section alongside the existing punctuation toggle. Validated: build succeeds, toggle persists across restarts, other audio is silenced on hotkey press and restored when dictation ends. Co-authored-by: Monawwar Abdullah <hello@monawwar.io>
1 parent 0704903 commit 9c6dc44

3 files changed

Lines changed: 68 additions & 0 deletions

File tree

Wave/AppState.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ final class AppState {
3636
var includePunctuation: Bool {
3737
didSet { UserDefaults.standard.set(includePunctuation, forKey: "includePunctuation") }
3838
}
39+
var muteSystemAudio: Bool {
40+
didSet { UserDefaults.standard.set(muteSystemAudio, forKey: "muteSystemAudio") }
3941
var customVocabulary: [String] {
4042
didSet { UserDefaults.standard.set(customVocabulary, forKey: "customVocabulary") }
4143
}
@@ -69,6 +71,7 @@ final class AppState {
6971
} else {
7072
includePunctuation = UserDefaults.standard.bool(forKey: "includePunctuation")
7173
}
74+
muteSystemAudio = UserDefaults.standard.bool(forKey: "muteSystemAudio")
7275
customVocabulary = UserDefaults.standard.stringArray(forKey: "customVocabulary") ?? []
7376

7477
// Default shortcut: Control + Space
@@ -134,8 +137,10 @@ final class AppState {
134137
do {
135138
status = .recording
136139
showOverlay()
140+
if muteSystemAudio { SystemAudioDucker.duck() }
137141
try await transcriptionService.startRecording()
138142
} catch {
143+
if muteSystemAudio { SystemAudioDucker.restore() }
139144
status = .error("Recording failed")
140145
hideOverlay()
141146
try? await Task.sleep(for: .seconds(2))
@@ -147,6 +152,8 @@ final class AppState {
147152
status = .transcribing
148153
updateOverlay()
149154

155+
let text = await transcriptionService.stopRecordingAndTranscribe(includePunctuation: includePunctuation)
156+
if muteSystemAudio { SystemAudioDucker.restore() }
150157
let prompt = customVocabulary.isEmpty ? nil : customVocabulary.joined(separator: ", ")
151158
let text = await transcriptionService.stopRecordingAndTranscribe(includePunctuation: includePunctuation, initialPrompt: prompt)
152159

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import CoreAudio
2+
3+
/// Saves and restores the default output device's mute state around a dictation session.
4+
/// Uses CoreAudio's `kAudioDevicePropertyMute` rather than changing volume so the
5+
/// user's volume setting is never touched.
6+
enum SystemAudioDucker {
7+
private static var savedMuteState: Bool = false
8+
9+
/// Snapshot the current mute state, then mute system output.
10+
static func duck() {
11+
savedMuteState = isMuted()
12+
setMuted(true)
13+
}
14+
15+
/// Restore the mute state captured at the last `duck()` call.
16+
static func restore() {
17+
setMuted(savedMuteState)
18+
}
19+
20+
// MARK: - Private
21+
22+
private static func isMuted() -> Bool {
23+
guard let device = defaultOutputDevice() else { return false }
24+
var mute: UInt32 = 0
25+
var size = UInt32(MemoryLayout<UInt32>.size)
26+
var address = mutePropertyAddress()
27+
let status = AudioObjectGetPropertyData(device, &address, 0, nil, &size, &mute)
28+
return status == noErr && mute != 0
29+
}
30+
31+
private static func setMuted(_ muted: Bool) {
32+
guard let device = defaultOutputDevice() else { return }
33+
var mute = UInt32(muted ? 1 : 0)
34+
var address = mutePropertyAddress()
35+
// Silently ignore devices that don't support the mute property (e.g. some BT sinks)
36+
AudioObjectSetPropertyData(device, &address, 0, nil, UInt32(MemoryLayout<UInt32>.size), &mute)
37+
}
38+
39+
private static func defaultOutputDevice() -> AudioDeviceID? {
40+
var device = AudioDeviceID(kAudioObjectUnknown)
41+
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
42+
var address = AudioObjectPropertyAddress(
43+
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
44+
mScope: kAudioObjectPropertyScopeGlobal,
45+
mElement: kAudioObjectPropertyElementMain
46+
)
47+
let status = AudioObjectGetPropertyData(
48+
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device
49+
)
50+
return (status == noErr && device != kAudioObjectUnknown) ? device : nil
51+
}
52+
53+
private static func mutePropertyAddress() -> AudioObjectPropertyAddress {
54+
AudioObjectPropertyAddress(
55+
mSelector: kAudioDevicePropertyMute,
56+
mScope: kAudioDevicePropertyScopeOutput,
57+
mElement: kAudioObjectPropertyElementMain
58+
)
59+
}
60+
}

Wave/Views/HomeView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ struct HomeView: View {
5252
Text("Transcription")
5353
.font(.headline)
5454
Toggle("Include punctuation", isOn: $state.includePunctuation)
55+
Toggle("Mute system audio while dictating", isOn: $state.muteSystemAudio)
5556
}
5657

5758
// Dictionary section

0 commit comments

Comments
 (0)