Skip to content

chore: add per-app Volume Control mixer as a Preview tab (#332)#1298

Merged
laurentiu021 merged 3 commits into
mainfrom
chore/volume-control-preview
Jul 5, 2026
Merged

chore: add per-app Volume Control mixer as a Preview tab (#332)#1298
laurentiu021 merged 3 commits into
mainfrom
chore/volume-control-preview

Conversation

@laurentiu021

Copy link
Copy Markdown
Owner

Summary

Turns 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 stays held at 1.52.37, so this lands as chore: — no version bump, no release.

Closes #332 (partial — v1 preview scope; see deferred items below).

What it does (v1 preview scope)

  • Lists each app currently playing on the default render endpoint with its own volume slider, a mute toggle, and a live peak meter.
  • Reads/controls audio through 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.
  • Does no background work while the tab is hidden (poll loop + meter timer both gated on IsActive).

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, since the tab is navigation-scoped.
  • AudioMixerViewModel : ViewModelBase mirrors ProcessManagerViewModel — a ~1 s reconcile loop gated by IsActive plus one shared DispatcherTimer for the meters. Rows reconcile in place by session id (a ReplaceWith Reset would drop a slider mid-drag). AudioSessionRowViewModel propagates 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 for protected processes); expired sessions dropped, system-sounds flagged; input clamped/validated at the service boundary.

Deferred (documented preview limitations)

  • Per-app output-device routing — only reachable via an undocumented, version-fragile internal WinRT class with a silent-mute failure mode on an invalid device. Out of scope for the preview on safety grounds.
  • Volume presets/profiles and a tray flyout mixer — planned for a later release.

Testing

  • 16 NSubstitute tests against IAudioMixerService (no audio hardware): 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 (was 3316; +16). All four projects build with 0 warnings / 0 errors.
  • Real-COM smoke test against the live endpoint: 10 sessions enumerated with correct names/PIDs, system-sounds flagged and sorted last, re-enumeration and get/set verified — proving the interop vtable order is correct.

Docs (same PR)

  • README — Volume Control marked Preview; counts updated 53->54 implemented / 5->4 WIP.
  • ARCHITECTURE — new VM, service, and interface seam documented.
  • CHANGELOGAdded entry under [Unreleased].

Note

Stays PREVIEW after merge — the badge does not come off until at least two post-implementation audits pass.

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.
@laurentiu021
laurentiu021 merged commit 4078c2b into main Jul 5, 2026
4 checks passed
@laurentiu021
laurentiu021 deleted the chore/volume-control-preview branch July 5, 2026 17:13
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.

[Enhancement]: Customization — Per-App Volume Mixer (EarTrumpet-style)

2 participants