chore: add per-app Volume Control mixer as a Preview tab (#332)#1298
Merged
Conversation
Turn the Volume Control placeholder in the Customization group into a working per-app volume mixer, shipped behind the PREVIEW badge (inDevelopment stays true) until it passes post-implementation audits. Beta remains held at 1.52.37, so this lands as chore: (no version bump, no release). What it does (v1 preview scope): - Lists each app playing on the DEFAULT render endpoint with a volume slider, mute toggle, and a live peak meter. - Reads/controls audio via the documented Windows Core Audio APIs using raw ComImport interop for six interfaces (IMMDeviceEnumerator, IAudioSessionManager2, IAudioSessionEnumerator, IAudioSessionControl2, ISimpleAudioVolume, IAudioMeterInformation) — no new NuGet dependency, keeping the single portable .exe. Architecture (mirrors the existing seam idiom): - IAudioMixerService seam; all COM stays inside AudioMixerService (sealed, IDisposable), which holds the manager/enumerator open across polls and releases every RCW deterministically in Dispose (not finalizer-only — the tab is navigation-scoped). - AudioMixerViewModel : ViewModelBase mirrors ProcessManagerViewModel — a ~1s reconcile loop gated by IsActive plus one shared DispatcherTimer for the meters, both paused when the tab is hidden. Rows reconcile IN PLACE by session id (a ReplaceWith Reset would drop a slider mid-drag). Row VMs propagate volume/mute with a re-entrancy guard so an external change surfaced by a refresh is not echoed back. - Sessions grouped by owning process (Volume Mixer mental model); name/icon resolved from the PID via the existing IconExtractorService (fallback-safe); expired sessions dropped, system-sounds session flagged; input clamped/validated at the service boundary. Explicitly deferred (documented as preview limitations): per-app output-device routing (only reachable via an undocumented, version-fragile internal WinRT class with a silent-mute failure mode), volume presets, and a tray flyout. Tests: 16 NSubstitute tests against IAudioMixerService (no audio hardware) covering population, in-place reconcile with a no-Reset assertion, volume/mute propagation + echo suppression, peak updates, the IsActive gate, empty-state, ordering, idempotent dispose, and trust-boundary rejection. Full suite 3332 green. Real-COM interop smoke-tested against the live endpoint (10 sessions enumerated, names/PIDs/system-sounds correct, re-enumeration + get/set verified). Docs: README feature table (Volume Control now Preview, counts 53->54 implemented / 5->4 WIP), ARCHITECTURE (VM + service + interface seam), CHANGELOG Added entry.
| // ── Core Audio COM interop (documented interfaces, exact vtable order) ── | ||
|
|
||
| private static readonly Guid CLSID_MMDeviceEnumerator = new("BCDE0395-E52F-467C-8E3D-C4579291692E"); | ||
| private static Guid IID_IAudioSessionManager2 = new("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"); |
…ld, session keying (#332) Audit #1 (adversarial code review) found a reachable critical crash and a batch of correctness/perf/test issues on the preview. All fixed in this pass; the tab now renders in both themes (verified via FlaUI, 9 real sessions). Critical (crash): the mute control was a <ToggleButton> styled with GhostButton, a Button-targeted Style — WPF throws when the row template realizes, blanking the tab the moment any app plays audio. VM-only tests never render XAML so CI stayed green. Fixed by making it a <Button> (the two-state look is driven by the IsMuted DataTrigger, not toggle chrome). Verified by launching the app and rendering the tab. High (perf): ResolveProcess (Process.GetProcessById/MainModule) ran inside the COM _gate, so the UI-thread GetPeak (50ms) and slider SetVolume contended on the same lock every ~1s reconcile. Split GetSessions into a locked phase (fast COM reads only, RCWs cached) and an unlocked phase (identity resolution), with a per-PID name/path cache. Correctness: session grouping now keys on the session-INSTANCE identifier (GetSessionInstance Identifier, stripped of the per-stream GUID) instead of the recyclable PID — a reused PID can no longer map two apps onto one row. This fixes the stale-icon and doc-vs-impl findings at the root; ApplyUpdate also re-extracts the icon/PID defensively if identity changes. The AudioSessionInfo doc now matches the implementation. UX: added a mid-drag guard (IsUserAdjusting, set on Slider Thumb.DragStarted/Completed) so a background refresh no longer snaps the thumb back to a stale value while the user drags. A11y: the decorative peak meter is no longer named in the automation tree (matches the Dashboard live meters); the interactive slider keeps its name. Tests: replaced the vacuous Assert.True(true) dispose test with a behavioral one (Dispose now zeroes peaks and is idempotent), corrected the class-summary IsActive-coverage claim, and added tests for the mid-drag guard and the identity refresh. Suite green (+ new tests).
…el, drag guard, tests (#332) Independent Audit #2 passed (all Audit #1 fixes confirmed, no regressions) with six low-severity nits. Cleared them for portfolio quality: - Identity cache now keyed on the stable group key, not the recyclable PID, so a reused PID that opens a new session always misses and re-resolves — a row can never show a prior process's stale name/icon. - GetProcessId HRESULT is honored: a genuine app whose PID can't be read is labeled "Unknown app", not mislabeled "System Sounds". The check accepts any SUCCESS HRESULT (>= 0) — including AUDCLNT_S_NO_SINGLE_PROCESS (0x0008900F, a valid multi-process session) — after a live render showed the naive "== S_OK" check dropped a real app to "Unknown app". Verified by re-render (HP Audio Control resolves correctly again). - StripStreamGuid extracted as internal static + table-driven tests, so the load-bearing PID-reuse group-key derivation is unit-tested (normal strip, no-marker, leading-marker, empty/null, two-streams-same-app). - Mid-adjust guard now also fires on keyboard focus (GotKeyboardFocus/LostKeyboardFocus), so keyboard/track-click adjustments get the same protection as mouse-drag. - Mid-drag test now also asserts the live drag DOES propagate to the service; dispose-test comment corrected (idempotency is from the self-idempotent derived body, not the base guard). Full suite green (3340); tab re-rendered clean in dark + light.
This was referenced Jul 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Turns the Volume Control placeholder in the Customization group into a working per-app volume mixer, shipped behind the PREVIEW badge (
inDevelopmentstaystrue) until it passes post-implementation audits. Beta stays held at 1.52.37, so this lands aschore:— no version bump, no release.Closes #332 (partial — v1 preview scope; see deferred items below).
What it does (v1 preview scope)
ComImportinterop for six interfaces (IMMDeviceEnumerator->IAudioSessionManager2->IAudioSessionEnumerator->IAudioSessionControl2/ISimpleAudioVolume/IAudioMeterInformation) — no new NuGet dependency, keeping the single portable .exe.IsActive).Architecture (mirrors the existing seam idiom)
IAudioMixerServiceseam; all COM stays insideAudioMixerService(sealed,IDisposable), which holds the manager/enumerator open across polls and releases every RCW deterministically inDispose— not finalizer-only, since the tab is navigation-scoped.AudioMixerViewModel : ViewModelBasemirrorsProcessManagerViewModel— a ~1 s reconcile loop gated byIsActiveplus one sharedDispatcherTimerfor the meters. Rows reconcile in place by session id (aReplaceWithReset would drop a slider mid-drag).AudioSessionRowViewModelpropagates volume/mute with a re-entrancy guard so an external change surfaced by a refresh is not echoed back.IconExtractorService(fallback-safe for protected processes); expired sessions dropped, system-sounds flagged; input clamped/validated at the service boundary.Deferred (documented preview limitations)
Testing
IAudioMixerService(no audio hardware): population, in-place reconcile with a no-Reset assertion, volume/mute propagation + echo suppression, peak updates, theIsActivegate, empty-state, ordering, idempotent dispose, and trust-boundary rejection.Docs (same PR)
Addedentry under[Unreleased].Note
Stays PREVIEW after merge — the badge does not come off until at least two post-implementation audits pass.