How OpenSuperWhisper Dynamically Updates Available Microphones in the macOS Status Bar
OpenSuperWhisper uses a Combine-based publisher-subscriber pattern where MicrophoneService monitors system audio devices via AVCaptureDevice notifications, publishes changes to availableMicrophones, and triggers AppDelegate to rebuild the status bar menu whenever microphones are connected or disconnected.
OpenSuperWhisper is a macOS transcription application that keeps its status bar menu synchronized with the system's current audio input devices in real-time. The app dynamically updates the list of available microphones using a reactive architecture built on Apple's Combine framework and AVFoundation notifications. This ensures users always see an accurate, up-to-date device list without requiring manual refreshes or app restarts.
The Three-Component Architecture
The dynamic status bar menu relies on three tightly-coupled components that separate data management from UI presentation.
MicrophoneService – The Central Source of Truth
Located in OpenSuperWhisper/MicrophoneService.swift, this service class manages audio device discovery and maintains application state. It exposes two critical @Published properties: availableMicrophones and selectedMicrophone.
The service registers for system notifications through its setupDeviceMonitoring() method, listening for AVCaptureDeviceWasConnected and AVCaptureDeviceWasDisconnected. When hardware changes occur, refreshAvailableMicrophones() queries the current device list and updates the published array, automatically notifying all Combine subscribers.
AppDelegate – The Status Bar Observer
The AppDelegate class in OpenSuperWhisper/OpenSuperWhisperApp.swift hosts the NSStatusItem and manages the menu lifecycle. During applicationDidFinishLaunching, it calls setupStatusBarItem() and establishes a Combine subscription to MicrophoneService.shared.$availableMicrophones.
This subscription, stored as an AnyCancellable named microphoneObserver, executes rebuildMicrophoneMenu() whenever the microphone array changes, ensuring the UI always reflects the latest hardware configuration.
rebuildMicrophoneMenu – The UI Synchronization Method
This private method in AppDelegate completely reconstructs the Microphones submenu to reflect current system state. It clears existing items using removeAllItems(), iterates over MicrophoneService.shared.availableMicrophones, and creates NSMenuItem instances for each device.
Each menu item uses device.displayName as its title and stores the device object in representedObject. The currently selected microphone displays a checkmark by setting item.state = .on when the device ID matches selectedMicrophone?.id.
Complete Data Flow from Hardware to UI
- System Event: macOS detects a hardware change (USB mic plugged/unplugged) and posts
AVCaptureDeviceWasConnectedorAVCaptureDeviceWasDisconnected. - Service Update:
MicrophoneServicereceives the notification, callsrefreshAvailableMicrophones(), and updates its@Published var availableMicrophones. - Publisher Emission: The Combine framework emits the new array to all subscribers.
- Menu Rebuild:
AppDelegate's subscription receives the update on the main run loop and executesrebuildMicrophoneMenu(). - UI Refresh: The status bar menu clears old items and repopulates with current devices, preserving the selected microphone's checkmark state.
Implementation Details
Subscribing to Microphone Changes
The observer pattern uses Combine to bridge the service and UI layers:
private func observeMicrophoneChanges() {
microphoneObserver = MicrophoneService.shared.$availableMicrophones
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.rebuildMicrophoneMenu()
}
}
Rebuilding the Microphone Submenu
The menu construction handles dynamic creation and state synchronization:
private func rebuildMicrophoneMenu() {
// Remove any old microphone items
microphoneSubmenu?.removeAllItems()
// Create a fresh submenu if needed
if microphoneSubmenu == nil {
microphoneSubmenu = NSMenu(title: "Microphones")
statusItem?.menu?.addItem(
NSMenuItem(title: "Microphones", action: nil, keyEquivalent: "")
.then { $0.submenu = microphoneSubmenu }
)
}
for device in MicrophoneService.shared.availableMicrophones {
let item = NSMenuItem(title: device.displayName,
action: #selector(selectMicrophone(_:)),
keyEquivalent: "")
item.target = self
item.representedObject = device
item.state = (device.id == MicrophoneService.shared.selectedMicrophone?.id) ? .on : .off
microphoneSubmenu?.addItem(item)
}
}
Handling User Selection
When a user clicks a microphone name, the action updates the service state:
@objc private func selectMicrophone(_ sender: NSMenuItem) {
if let device = sender.representedObject as? MicrophoneService.AudioDevice {
MicrophoneService.shared.selectMicrophone(device)
}
}
Key Source Files
OpenSuperWhisper/MicrophoneService.swift: Core discovery logic,@Publishedproperties, andsetupDeviceMonitoring().OpenSuperWhisper/OpenSuperWhisperApp.swift: ContainsAppDelegate,rebuildMicrophoneMenu(), and Combine subscription setup.OpenSuperWhisper/Utils/AudioUtil.swift: Helper functions for device classification (e.g.,isBuiltInDevice).
Summary
- Combine Framework: Enables reactive updates from
MicrophoneServicetoAppDelegatewithout tight coupling. - AVCaptureDevice Notifications: System-level events trigger automatic list refreshes via
setupDeviceMonitoring(). - Complete Menu Rebuild: The
rebuildMicrophoneMenu()method reconstructs the UI from scratch on every change, ensuring consistency with system state. - State Preservation: Selected microphone checkmarks persist across updates by comparing device IDs against
selectedMicrophone.
Frequently Asked Questions
How does the app detect when a new microphone is plugged in?
MicrophoneService registers for AVCaptureDeviceWasConnected and AVCaptureDeviceWasDisconnected notifications in its setupDeviceMonitoring() method. When macOS posts these notifications, the service calls refreshAvailableMicrophones() to query the current hardware and update its @Published property, triggering a menu rebuild.
Why does the entire menu rebuild instead of updating individual items?
The rebuildMicrophoneMenu() approach clears all existing items and reconstructs the menu from availableMicrophones. This ensures the order and content exactly match the system state without complex diffing logic, preventing stale entries when devices are disconnected or renamed.
What happens if the currently selected microphone is disconnected?
When refreshAvailableMicrophones() runs after a disconnection, it updates availableMicrophones to exclude the removed device. The Combine subscription fires, rebuildMicrophoneMenu() executes, and the menu repopulates without the disconnected mic. The checkmark disappears until the user selects a new available device.
Is the microphone list updated on a background thread?
Device discovery runs on background threads, but UI updates occur on the main run loop. The subscription uses .receive(on: RunLoop.main) to ensure rebuildMicrophoneMenu() executes on the main thread, preventing crashes when modifying NSMenu items.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →