feat: add Devices page with live cards, peak meter and Ctrl+Z undo#32
Merged
Conversation
The Devices page (replacing the old Home placeholder in the nav rail) lists every active audio endpoint as a card with: - A log-scaled, colour-sectioned peak meter (green / amber / red bands at -12 / -6 / 0 dBFS, with narrow gradient blends straddling each threshold) and a 1.5 s peak-hold marker. - A volume slider that auto-mutes at 0 % and auto-unmutes the moment the user drags off 0; a Windows system WAV ping plays on each commit. - A mute toggle that doubles as the device icon. The icon greys + drops its chip background when a rule pins the mute state. - A hide / show eye toggle. Cards sort defaults first (output before input), then Wave Link mix targets, then Wave Link virtual channels, then alphabetical. Non-default devices with no rules auto-hide; user-pinned devices override. - A summary of every enabled rule that targets the device (name + evaluator status + match count), collapsed to one chip by default with a chevron to expand. Clicking a chip jumps to the Rules page with that rule expanded and scrolled into view, all other rules collapsed. Layout is a custom WrapByRowLayout: cards in a row align to a shared baseline height except when a card is in chevron-expanded mode, where it grows alone. Audio rules engine title replaces "Per-app audio routing" on the title bar. feat(rules): add ApplicationRunning and ApplicationNotRunning conditions feat(audio): reconcile external mute changes via AudioEndpointVolume notifications and fire a Windows toast naming the locking rule feat(home): Ctrl+Z undo for hide / show, volume drags and mute toggles feat(home): persist window size across launches feat(ui): replace per-page subtext lines with info-icon tooltips chore: rename CLAUDE.md to AGENTS.md and document event-driven preference
SetDeviceVolume / MuteDevice / UnmuteDevice are flow-agnostic - the applier matches them against render and capture endpoints alike. The evaluator was passing action.EffectiveFlow (which falls back to Render) to MatchEndpoint, so a MuteDevice rule on a microphone was classified Idle even while the applier kept it muted. Cascading effect: DeviceRulesSummary cleared the mute lock, the card's mute icon became clickable, the user could unmute, and ExternalMuteChanged had no rule target to reconcile against - so the next applier pass silently re-muted with no toast. Add MatchEndpointAnyFlow and use it for volume/mute actions.
GetPeakLevel was allocating a fresh MMDevice via the enumerator on every call. The Devices page polls at ~20Hz per visible card, so a typical six- device layout was burning ~120 COM activations per second. Route the read through the long-lived MMDevice held by the mute subscription (added in this PR for OnVolumeNotification); fall back to the enumerator path only when no subscription exists.
…class Pull the capped LIFO of reversible Devices-page actions out of HomeViewModel into its own class. The previous in-line trim re-stacked the whole undo stack to drop the oldest entry on overflow; LinkedList makes that an O(1) RemoveFirst. HomeViewModel now just calls PushVisibility / PushVolumeMute / TryPop, and the apply-on-pop switch stays where it belongs - on the view-model that knows the cards. Also rename Controls/MasonryLayout.cs to WrapByRowLayout.cs so the file matches the class it defines.
PR #31 bumped hoobio/pipeline-tools from v1.5.0 to v2.2.1 without updating the SBOM job to v2's API. v1 took a single `channel: ci/<branch>` string; v2 splits it into `channel: ci` + `sub-channel: <branch>`, and the bootstrap now treats any `<component>@ci/<X>` project it finds as a legacy v1 entry that needs migrating out. With the v1-style channel still in place, the bootstrap was creating `earmark-app@ci/<branch>`, then the migration sweep immediately rewrote and deleted it, then the upload step 404'd on the missing parent. Switch the resolver to emit channel + sub-channel separately, promote the default branch (main) to top-level (`earmark-app@main`, sibling of release) instead of nesting it under ci, and pass the new sub-channel + default-branch inputs to the upload action. Effective parent name (sub-channel if set, else channel) is exposed as a separate output so the project-tag value stays aligned with the new v2 hierarchy.
hoobio
added a commit
that referenced
this pull request
May 28, 2026
## Summary Replaces the Home placeholder in the nav rail with a full Devices page that lists every active audio endpoint as a live card. Each card surfaces the device's current peak level on a dB-accurate sectioned meter, a master volume slider that doubles as a mute control, a hide/show toggle, and a compact summary of every rule that touches the device. Clicking a rule chip jumps to the Rules page with that rule expanded and scrolled into view. Plus rule-condition support for "Application running" / "Application not running", external mute reconciliation with Windows toast notifications naming the locking rule, Ctrl+Z undo across hide/show, volume drags, and mute toggles, and persistent window sizing. ## Changes - New **Devices page** (`HomePage` + `HomeViewModel` + `DeviceCard` + `DeviceRulesSummary`) with custom `WrapByRowLayout` so chevron-expanded cards grow alone without dragging their row mates with them. - **Peak meter** (`PingPlayer` swapped to Windows system WAV; sectioned bar at -12 / -6 / 0 dBFS thresholds with gradient transitions; peak-hold marker with 1.5 s latch + decay; log-scaled across -60 to 0 dBFS). - **Auto-mute on 0 % volume, auto-unmute on drag** with rule lock honoured. - **Rule conditions**: `ApplicationRunning` / `ApplicationNotRunning` types in `RuleCondition`; `IRuleMatcher.ConditionsMet` now takes sessions; `RuleEvaluator` updated. - **External mute reconciliation**: per-device `AudioEndpointVolume.OnVolumeNotification` subscriptions in `AudioEndpointService` raise `ExternalMuteChanged`; `HomeViewModel` snaps the device back to the rule''s target and fires a `Microsoft.Windows.AppNotifications` toast naming the locking rule (rate-limited per device). 250 ms poll kept as fallback. - **Ctrl+Z undo** for `VisibilityUndo` (hide/show) and `VolumeMuteUndo` (slider drag and mute icon click). - **Window size persisted** to `AppSettings.WindowWidth` / `WindowHeight`, restored via `AppWindow.Resize` on `Attach`. - **WaveLink snapshot equality** fixed (`ReferenceEquals` -> structural compare) so `SnapshotChanged` no longer fires every 5 s poll. - **Title bar** renamed to "Audio rules engine"; per-page subtext lines collapsed into info-icon tooltips. - **CLAUDE.md -> AGENTS.md** rename plus a new "Reactivity preferences" section documenting event-driven-first, polling-as-fallback. ## Follow-up commits (review pass) - **`fix(rules)`**: volume / mute rules targeting a capture device (microphone) were being classified `Idle` by the evaluator because `EffectiveFlow` defaults to `Render` for those action types. The applier was correctly muting the mic, but `DeviceRulesSummary` then cleared `IsMuteLockedByRule`, the card''s mute icon stayed clickable, the user could unmute, and `ApplyExternalMute` had no rule target to reconcile against. Added `MatchEndpointAnyFlow` and used it for `SetDeviceVolume` / `MuteDevice` / `UnmuteDevice`. - **`perf(audio)`**: `GetPeakLevel` now reuses the long-lived `MMDevice` already held by the mute subscription, instead of allocating a fresh COM wrapper on every 50 ms poll. - **`refactor(home)`**: pulled the capped undo LIFO out of `HomeViewModel` into its own `DeviceUndoStack`. The previous in-line trim re-stacked the whole stack to drop the oldest entry; `LinkedList` makes that an O(1) `RemoveFirst`. Also renamed `MasonryLayout.cs` to match its only class, `WrapByRowLayout`. - **`ci(sbom)`**: the SBOM job was still using v1''s single `channel: ci/<branch>` input after the `pipeline-tools` v1 -> v2 bump in #31. v2 requires `channel: ci` + `sub-channel: <branch>` (or `channel: <default-branch>` for main), and the bootstrap migration was deleting the just-created upload parent on every run. Switched to the v2 channel + sub-channel pattern. - **`docs(readme)`**: replaced the single hero image with separate Devices / Rules screenshots. ## Testing - [x] Manual testing on Windows 11 host (build 26200) - [x] Tail of the latest log file shows expected `Applied rule ...` lines after launch - [ ] Unit tests added/updated under `tests/Earmark.Core.Tests` - [x] Existing tests pass (`dotnet test -p:Platform=x64`) ## Checklist - [x] PR title follows Conventional Commits - [x] Code builds without warnings (`dotnet build src/Earmark.App/Earmark.App.csproj -c Debug -p:Platform=x64`) - [x] No emoji / gitmoji in commit messages or PR title - [x] Architecture boundary respected: `Earmark.Core` stays UI-free; Windows / NAudio interop in `Earmark.Audio`; UI / VMs / settings in `Earmark.App` - [x] AGENTS.md updated (rename + reactivity section) --- <!-- release-please footers: one bare conventional-commit line per extra changelog entry, separated by blank lines. --> feat(rules): add ApplicationRunning and ApplicationNotRunning conditions feat(audio): reconcile external mute changes via AudioEndpointVolume notifications and fire a Windows toast naming the locking rule feat(home): Ctrl+Z undo for hide / show, volume drags and mute toggles feat(home): persist window size across launches feat(ui): replace per-page subtext lines with info-icon tooltips fix(rules): mark volume / mute rules targeting capture devices as Active perf(audio): reuse cached MMDevice for peak-level reads to avoid per-tick COM activations chore: rename CLAUDE.md to AGENTS.md and document event-driven reactivity preference
2 tasks
hoobio
added a commit
that referenced
this pull request
May 28, 2026
## Summary Updates AGENTS.md "Multi-change PRs" section and `.github/pull_request_template.md` to specify that conventional-commit footers in a squash-merge PR description body must be **blank-line-separated**. This is what was missing on PR #32 (`7ea82f9`) which only produced one changelog entry out of the eight intended. ## Why release-please's parser groups consecutive non-blank lines into a single paragraph and only treats the first conventional-commit line per paragraph as a changelog entry. The old guidance and PR template hint both showed footers stacked without blank lines, which is why PR #32's `feat(audio):`, `feat(home)` x2, `feat(ui):`, `fix(rules):`, `perf(audio):`, and `chore:` footers were silently dropped. Cross-checked against command-palette-bitwarden squash commits (e.g. `60e48c7`) which work because each footer is blank-line-separated. ## Changes - **AGENTS.md** "Multi-change PRs": - Example uses blank-line-separated footers (matching the layout release-please actually parses). - Explicit rule and reason for the blank line requirement. - Note that soft-wrapping within a single footer is fine. - Correct the old "only feat/fix/perf/revert produce changelog entries" bullet: every type produces an entry because `release-please-config.json` sets `hidden: false` on all sections; only the version-bump effect differs. - **`.github/pull_request_template.md`**: hint updated with the same convention, pointer changed from CLAUDE.md to AGENTS.md. ## Already done The bad PR #32 squash commit message has been amended on main and force-pushed (with the `main` ruleset briefly disabled and re-enabled) so release-please will pick up the missed entries on its next run. See open PR #29 once it refreshes. ## Testing - [x] Local diff review against the working command-palette-bitwarden footer format - [ ] Wait for release-please workflow to re-run after this merges and confirm PR #29 lists the additional `feat(audio)`, `feat(home)`, `feat(ui)`, `perf(audio)`, `chore` entries
hoobio
added a commit
that referenced
this pull request
May 29, 2026
Resolve the #32 add/add conflicts in favour of this branch (a strict superset of the Devices-page work main has as f7951e8). Combine AGENTS.md: keep this branch's ExpanderPill / Fluent 2 sections and adopt main's #33 blank-line footer rule. Take main's updated PR template. Keep CLAUDE.md (main dropped it; this branch needs it as the Claude Code context pointer).
hoobio
pushed a commit
that referenced
this pull request
May 29, 2026
This PR was generated automatically by [release-please](https://github.com/googleapis/release-please). --- ## [0.1.7](v0.1.6...v0.1.7) (2026-05-29) ### Features * add a setting to reconcile Wave Link device names ([0f7d12c](0f7d12c)) * add Devices page with live cards, peak meter and Ctrl+Z undo ([#32](#32)) ([f7951e8](f7951e8)) * **audio:** reconcile external mute changes via AudioEndpointVolume ([f7951e8](f7951e8)) * **home:** Ctrl+Z undo for hide / show, volume drags and mute toggles ([f7951e8](f7951e8)) * **home:** match device-card icons to Wave Link mixes ([0f7d12c](0f7d12c)) * **home:** persist window size across launches ([f7951e8](f7951e8)) * **rules:** add ApplicationRunning and ApplicationNotRunning conditions ([f7951e8](f7951e8)) * **ui:** replace per-page subtext lines with info-icon tooltips ([f7951e8](f7951e8)) * **ui:** right-click menus to hide a device and enable or delete a rule ([0f7d12c](0f7d12c)) * Wave Link card theming, app theme and per-device apps row ([#34](#34)) ([0f7d12c](0f7d12c)) * **wavelink:** route Wave Link input mute and volume over the WebSocket ([0f7d12c](0f7d12c)) ### Bug Fixes * **audio:** keep CoreAudio COM off the UI thread to stop hangs ([0f7d12c](0f7d12c)) * **audio:** release COM wrappers and adopt singleton view-model lifetime ([#28](#28)) ([694819e](694819e)) * **audio:** release MMDevice / AudioSessionControl wrappers and ([694819e](694819e)) * **rules:** mark volume / mute rules targeting capture devices as Active ([f7951e8](f7951e8)) ### Performance Improvements * **audio:** reuse cached MMDevice for peak-level reads to avoid ([f7951e8](f7951e8)) * **audio:** reuse the cached MMDevice and skip no-op mute notifications ([0f7d12c](0f7d12c)) ### Documentation * **release-please:** require blank lines between footer entries ([#33](#33)) ([4d21efc](4d21efc)) ### Miscellaneous Chores * rename CLAUDE.md to AGENTS.md and document event-driven ([f7951e8](f7951e8)) ### Build System * **deps:** Bump hoobio/pipeline-tools from 1.5.0 to 2.2.1 ([#31](#31)) ([afe3e45](afe3e45)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: release-please-hoobi[bot] <279189756+release-please-hoobi[bot]@users.noreply.github.com>
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
Replaces the Home placeholder in the nav rail with a full Devices page that lists every active audio endpoint as a live card. Each card surfaces the device's current peak level on a dB-accurate sectioned meter, a master volume slider that doubles as a mute control, a hide/show toggle, and a compact summary of every rule that touches the device. Clicking a rule chip jumps to the Rules page with that rule expanded and scrolled into view. Plus rule-condition support for "Application running" / "Application not running", external mute reconciliation with Windows toast notifications naming the locking rule, Ctrl+Z undo across hide/show, volume drags, and mute toggles, and persistent window sizing.
Changes
HomePage+HomeViewModel+DeviceCard+DeviceRulesSummary) with customWrapByRowLayoutso chevron-expanded cards grow alone without dragging their row mates with them.PingPlayerswapped to Windows system WAV; sectioned bar at -12 / -6 / 0 dBFS thresholds with gradient transitions; peak-hold marker with 1.5 s latch + decay; log-scaled across -60 to 0 dBFS).ApplicationRunning/ApplicationNotRunningtypes inRuleCondition;IRuleMatcher.ConditionsMetnow takes sessions;RuleEvaluatorupdated.AudioEndpointVolume.OnVolumeNotificationsubscriptions inAudioEndpointServiceraiseExternalMuteChanged;HomeViewModelsnaps the device back to the rule''s target and fires aMicrosoft.Windows.AppNotificationstoast naming the locking rule (rate-limited per device). 250 ms poll kept as fallback.VisibilityUndo(hide/show) andVolumeMuteUndo(slider drag and mute icon click).AppSettings.WindowWidth/WindowHeight, restored viaAppWindow.ResizeonAttach.ReferenceEquals-> structural compare) soSnapshotChangedno longer fires every 5 s poll.Follow-up commits (review pass)
fix(rules): volume / mute rules targeting a capture device (microphone) were being classifiedIdleby the evaluator becauseEffectiveFlowdefaults toRenderfor those action types. The applier was correctly muting the mic, butDeviceRulesSummarythen clearedIsMuteLockedByRule, the card''s mute icon stayed clickable, the user could unmute, andApplyExternalMutehad no rule target to reconcile against. AddedMatchEndpointAnyFlowand used it forSetDeviceVolume/MuteDevice/UnmuteDevice.perf(audio):GetPeakLevelnow reuses the long-livedMMDevicealready held by the mute subscription, instead of allocating a fresh COM wrapper on every 50 ms poll.refactor(home): pulled the capped undo LIFO out ofHomeViewModelinto its ownDeviceUndoStack. The previous in-line trim re-stacked the whole stack to drop the oldest entry;LinkedListmakes that an O(1)RemoveFirst. Also renamedMasonryLayout.csto match its only class,WrapByRowLayout.ci(sbom): the SBOM job was still using v1''s singlechannel: ci/<branch>input after thepipeline-toolsv1 -> v2 bump in build(deps): Bump hoobio/pipeline-tools from 1.5.0 to 2.2.1 #31. v2 requireschannel: ci+sub-channel: <branch>(orchannel: <default-branch>for main), and the bootstrap migration was deleting the just-created upload parent on every run. Switched to the v2 channel + sub-channel pattern.docs(readme): replaced the single hero image with separate Devices / Rules screenshots.Testing
Applied rule ...lines after launchtests/Earmark.Core.Testsdotnet test -p:Platform=x64)Checklist
dotnet build src/Earmark.App/Earmark.App.csproj -c Debug -p:Platform=x64)Earmark.Corestays UI-free; Windows / NAudio interop inEarmark.Audio; UI / VMs / settings inEarmark.Appfeat(rules): add ApplicationRunning and ApplicationNotRunning conditions
feat(audio): reconcile external mute changes via AudioEndpointVolume notifications and fire a Windows toast naming the locking rule
feat(home): Ctrl+Z undo for hide / show, volume drags and mute toggles
feat(home): persist window size across launches
feat(ui): replace per-page subtext lines with info-icon tooltips
fix(rules): mark volume / mute rules targeting capture devices as Active
perf(audio): reuse cached MMDevice for peak-level reads to avoid per-tick COM activations
chore: rename CLAUDE.md to AGENTS.md and document event-driven reactivity preference