Skip to content

feat: audio device selector in Settings#34

Merged
KrennicMS merged 1 commit intodevelopfrom
feature/audio-device-selector
Mar 11, 2026
Merged

feat: audio device selector in Settings#34
KrennicMS merged 1 commit intodevelopfrom
feature/audio-device-selector

Conversation

@KrennicMS
Copy link
Copy Markdown
Collaborator

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:

Audio Device to Monitor:
[ Default (Windows default output)  ▼ ]
  Speakers (Realtek High Definition Audio)
  Headphones (USB Audio Device)
  NVIDIA High Definition Audio
  • First item is always "Default (Windows default output)" → existing behaviour (no stored device ID)
  • Remaining items enumerated via MMDeviceEnumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active)
  • Populated fresh each time the Settings window opens (new devices are visible immediately)
  • Selection persisted as HomeAssistant.AudioDeviceId in %APPDATA%\...\appsettings.json

Behaviour

AudioDeviceId value Behaviour
Empty / null GetDefaultAudioEndpoint() — always follows Windows default
Set GetDevice(id) — monitors that specific device
Set but device missing Logs warning, falls back to Windows default

Files changed

File Change
IAppConfiguration New AudioDeviceId property
AppConfiguration Reads HomeAssistant:AudioDeviceId from config
appsettings.json New AudioDeviceId: "" field
VolumeWatcherService Device selection logic at startup
SettingsManager SaveSettings() gains optional audioDeviceId param
SettingsForm New ComboBox with device enumeration
SystemTrayService Passes device ID from form to settings

Tests

  • 3 new tests in AppConfigurationTests
  • 2 new tests in IAppConfigurationTests
  • 3 new tests in SettingsManagerTests

Platform note

Cannot run dotnet test locally — net8.0-windows requires Windows. CI on windows-latest is the test gate.

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
@MiguelTVMS MiguelTVMS requested a review from Copilot March 11, 2026 03:21
@KrennicMS KrennicMS merged commit f3cac98 into develop Mar 11, 2026
5 checks passed
@KrennicMS KrennicMS deleted the feature/audio-device-selector branch March 11, 2026 03:23
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 AudioDeviceId in configuration (IAppConfiguration/AppConfiguration) and default config (appsettings.json).
  • Updates VolumeWatcherService startup 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.

Comment on lines +80 to +102
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);
}
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +87
_defaultDevice = _deviceEnumerator.GetDevice(configuredDeviceId);
_logger.LogInformation("Using configured audio device: {DeviceName} ({DeviceId})",
_defaultDevice.FriendlyName, configuredDeviceId);
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Comment thread src/IAppConfiguration.cs
/// <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.
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// 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).

Copilot uses AI. Check for mistakes.
Comment thread src/SettingsManager.cs
Comment on lines +33 to 34
public void SaveSettings(string webhookUrl, string webhookId, string targetMediaPlayer, string audioDeviceId = "")
{
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
KrennicMS added a commit that referenced this pull request Mar 11, 2026
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)
KrennicMS added a commit that referenced this pull request Mar 11, 2026
* 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
@KrennicMS KrennicMS mentioned this pull request Mar 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants