feat: audio device selector in Settings#34
Conversation
Add a ComboBox to SettingsForm that lets the user choose which Windows audio output device VolumeWatcherService monitors, instead of always using the system default. Changes: - IAppConfiguration: add AudioDeviceId property - AppConfiguration: implement AudioDeviceId (HomeAssistant:AudioDeviceId config key) - appsettings.json: add AudioDeviceId field (default empty = Windows default output) - VolumeWatcherService: use configured device if AudioDeviceId is set; fall back to Windows default if device not found (COMException), log a warning - SettingsManager: add audioDeviceId parameter to SaveSettings() (optional, default '') save/update AudioDeviceId in appsettings.json; include in change-detection for Reload - SettingsForm: add ComboBox populated with active render endpoints via MMDeviceEnumerator.EnumerateAudioEndPoints(); first item is always 'Default (Windows default output)'; device selection saved and restored correctly - SystemTrayService: pass audioDeviceId from SettingsForm to SaveSettings() Tests: - AppConfigurationTests: 3 new tests for AudioDeviceId (configured, empty, missing) - IAppConfigurationTests: 2 new tests for AudioDeviceId via interface - SettingsManagerTests: 3 new tests for SaveSettings() with audioDeviceId - TestConfigurationHelper: add audioDeviceId parameter to CreateConfigurationWithWebhook() Resolves #32
There was a problem hiding this comment.
Pull request overview
Adds support for selecting a specific Windows audio output device to monitor (instead of always binding to the Windows default), wiring the selection through configuration, persistence, and the Settings UI.
Changes:
- Introduces
AudioDeviceIdin configuration (IAppConfiguration/AppConfiguration) and default config (appsettings.json). - Updates
VolumeWatcherServicestartup logic to monitor either the configured device or fall back to the Windows default (with logging on fallback). - Extends settings persistence/UI plumbing to save and restore the selected device.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/TestConfigurationHelper.cs | Adds AudioDeviceId into test configuration creation helper. |
| tests/SettingsManagerTests.cs | Adds tests verifying AudioDeviceId is persisted by SaveSettings. |
| tests/IAppConfigurationTests.cs | Adds contract tests for the new AudioDeviceId property. |
| tests/AppConfigurationTests.cs | Adds unit tests for AudioDeviceId value behavior (configured/empty/missing). |
| src/appsettings.json | Adds HomeAssistant:AudioDeviceId default value. |
| src/VolumeWatcherService.cs | Selects configured device via GetDevice(id) with fallback to default endpoint. |
| src/SystemTrayService.cs | Updates Settings form callback to pass audioDeviceId through to persistence. |
| src/SettingsManager.cs | Persists AudioDeviceId and includes it in reload verification. |
| src/SettingsForm.cs | Adds device selector ComboBox and enumerates active render endpoints. |
| src/IAppConfiguration.cs | Adds AudioDeviceId property to configuration interface with XML docs. |
| src/AppConfiguration.cs | Implements AudioDeviceId by reading HomeAssistant:AudioDeviceId. |
| var configuredDeviceId = _configuration.AudioDeviceId; | ||
| if (!string.IsNullOrEmpty(configuredDeviceId)) | ||
| { | ||
| try | ||
| { | ||
| _defaultDevice = _deviceEnumerator.GetDevice(configuredDeviceId); | ||
| _logger.LogInformation("Using configured audio device: {DeviceName} ({DeviceId})", | ||
| _defaultDevice.FriendlyName, configuredDeviceId); | ||
| } | ||
| catch (System.Runtime.InteropServices.COMException ex) | ||
| { | ||
| _logger.LogWarning(ex, | ||
| "Configured audio device {DeviceId} not found. Falling back to Windows default output.", | ||
| configuredDeviceId); | ||
| _defaultDevice = _deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); | ||
| } | ||
| } | ||
| else | ||
| { | ||
| _defaultDevice = _deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); | ||
| _logger.LogInformation("Using Windows default audio output device: {DeviceName}", | ||
| _defaultDevice.FriendlyName); | ||
| } |
There was a problem hiding this comment.
The new device-selection branches (configured device ID vs Windows default vs fallback) are not covered by tests. There are existing unit tests for VolumeWatcherService, but none assert that a configured AudioDeviceId causes GetDevice(id) to be used and that a missing device falls back to GetDefaultAudioEndpoint with a warning.
| _defaultDevice = _deviceEnumerator.GetDevice(configuredDeviceId); | ||
| _logger.LogInformation("Using configured audio device: {DeviceName} ({DeviceId})", | ||
| _defaultDevice.FriendlyName, configuredDeviceId); |
There was a problem hiding this comment.
_defaultDevice is now used to hold either the Windows default endpoint or a user-configured device. Renaming this field (and related log messages) to something like "_monitoredDevice" / "_selectedDevice" would better reflect its semantics after this change and reduce confusion when reading the rest of the service.
| /// <summary> | ||
| /// Gets the Windows audio device ID to monitor. | ||
| /// Empty string or null means use the Windows default output device. | ||
| /// The value is the MMDevice.ID GUID string from the Core Audio API. |
There was a problem hiding this comment.
The XML doc says the value is the "MMDevice.ID GUID string", but NAudio's MMDevice.ID is not a plain GUID; it's a Core Audio endpoint ID string (often in the form "{0.0.0.00000000}.{...guid...}"). Consider updating the wording to avoid implying callers can treat it as a GUID.
| /// The value is the MMDevice.ID GUID string from the Core Audio API. | |
| /// The value is the NAudio MMDevice.ID Core Audio endpoint ID string (for example "{0.0.0.00000000}.{<guid>}"), | |
| /// and should be treated as an opaque identifier (not a plain GUID). |
| public void SaveSettings(string webhookUrl, string webhookId, string targetMediaPlayer, string audioDeviceId = "") | ||
| { |
There was a problem hiding this comment.
The PR description says the default option should keep existing behavior by having no stored device ID, but SaveSettings always writes "HomeAssistant:AudioDeviceId" (defaulting to an empty string) into the user config. Either update the PR description/acceptance criteria to reflect that an empty string is persisted, or adjust SaveSettings to omit/remove the AudioDeviceId key when the default device is selected.
1. IAppConfiguration.AudioDeviceId doc: clarify it is a Core Audio endpoint ID string (opaque), not a plain GUID 2. VolumeWatcherService: rename _defaultDevice -> _monitoredDevice to better reflect semantics after audio device selector change 3. VolumeWatcherService: extract device resolution into internal static ResolveMonitoredDevice() with delegate injection for testability 4. SettingsManager: omit/remove AudioDeviceId key from JSON when value is empty (null/empty = Windows default, key should not be written) 5. Tests: 6 new [Fact] tests for ResolveMonitoredDevice covering all branches (configured device, empty/null -> default, COMException fallback with warning, log kind assertions); no COM/Windows required for these tests 6. SettingsManagerTests: update assertions to expect AudioDeviceId key omitted when empty; add test for reverting to default (key removal)
* fix: act on PR #34 Copilot review comments 1. IAppConfiguration.AudioDeviceId doc: clarify it is a Core Audio endpoint ID string (opaque), not a plain GUID 2. VolumeWatcherService: rename _defaultDevice -> _monitoredDevice to better reflect semantics after audio device selector change 3. VolumeWatcherService: extract device resolution into internal static ResolveMonitoredDevice() with delegate injection for testability 4. SettingsManager: omit/remove AudioDeviceId key from JSON when value is empty (null/empty = Windows default, key should not be written) 5. Tests: 6 new [Fact] tests for ResolveMonitoredDevice covering all branches (configured device, empty/null -> default, COMException fallback with warning, log kind assertions); no COM/Windows required for these tests 6. SettingsManagerTests: update assertions to expect AudioDeviceId key omitted when empty; add test for reverting to default (key removal) * fix: guard against null device.FriendlyName in ResolveMonitoredDevice * fix: act on PR #35 Copilot review comments 1. Rename test: SaveSettings_OmitsAudioDeviceId_WhenNotProvided (was 'Saves') 2. VolumeWatcherService.ResolveMonitoredDevice: update delegates to MMDevice? nullable; use Copilot-suggested null-safe pattern (device is not null guard before FriendlyName; COMException falls through to getDefaultDevice) 3. Update tests to remove stubDevice! null-forgiveness; update routing assertions to match new null-safe fallback semantics; add comments explaining why logInfo logging tests cannot assert 'configured'/'default' kind without a real COM MMDevice (integration-tested on Windows CI) * fix: act on PR #35 second Copilot review round 1. ResolveMonitoredDevice made generic (TDevice) with a getName delegate so tests can use string stubs instead of COM MMDevice — fully cross-platform 2. All ResolveMonitoredDevice tests rewritten using string-typed stubs: - routing tests now return real non-null values and assert correctly - LogsConfiguredKind and LogsDefaultKind now properly assert 'configured' and 'default' log kinds - 3 new tests: ReturnsConfiguredDevice, ReturnsDefaultDevice, ReturnsDefaultDevice_WhenConfiguredDeviceNotFound - no MMDevice or COM references in test file; NAudio using removed 3. Fix stale comment in ResolveMonitoredDevice_UsesGetDevice test (removed incorrect 'throw from getDefaultDevice' claim) 4. Tests use [Fact] not [WindowsFact] — intentional since no NAudio types are referenced; pure logic test via string stub * fix: clarify ResolveMonitoredDevice test region comment wording
Resolves #32
What this does
Adds a ComboBox to the Settings window that lets the user choose which Windows audio output device the app monitors, instead of always following the system default.
UI
The dropdown appears below Target Media Player Entity ID:
MMDeviceEnumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active)HomeAssistant.AudioDeviceIdin%APPDATA%\...\appsettings.jsonBehaviour
AudioDeviceIdvalueGetDefaultAudioEndpoint()— always follows Windows defaultGetDevice(id)— monitors that specific deviceFiles changed
IAppConfigurationAudioDeviceIdpropertyAppConfigurationHomeAssistant:AudioDeviceIdfrom configappsettings.jsonAudioDeviceId: ""fieldVolumeWatcherServiceSettingsManagerSaveSettings()gains optionalaudioDeviceIdparamSettingsFormComboBoxwith device enumerationSystemTrayServiceTests
AppConfigurationTestsIAppConfigurationTestsSettingsManagerTestsPlatform note
Cannot run
dotnet testlocally —net8.0-windowsrequires Windows. CI onwindows-latestis the test gate.