Commit Graph
206 Commits
Author SHA1 Message Date
EdnunpandClaude Fable 5 a46af2ee51 Resource phase 2: log rotation, streaming-scoped priority mode, crash cap, table ceiling
The four findings from the 2026-07-26 resource audit (the 'is anything abusing the
hardware over long runs' scan):

1. Diagnostic log rolls at a 50 MB cap: the current file closes with a pointer line
   and a fresh timestamped file continues the session, so a multi-day always-logging
   run produces a chain of capped files (aged out by the existing startup pruning)
   instead of one unbounded giant. Filenames gain a per-instance ordinal so rolls
   within one timestamp tick stay unique.

2. Priority mode is scoped to ACTUAL streaming: the levers (keep-awake, High
   priority, EcoQoS opt-out, fine timer, working-set lock) engage when audio is
   moving (send armed to a peer, or received audio hitting a live session) and
   release after a 30 s quiet hold-down - no flapping across brief silences. An
   idle-in-tray RemSound no longer holds the machine awake for hours. The service
   already scoped this way; the app now matches. The audio loops' own fine-timer
   scopes (the overnight-lag fix) are independent of Priority mode and untouched.

3. Crash reports are capped at the newest 10, pruned unconditionally at startup -
   nothing ever matched crash-*.txt before, so they accumulated forever.

4. The receiver's peer-security status cache gets a 256-entry ceiling (the one
   IP-keyed table with no eviction; only reachable unfiltered + WAN-exposed).

New gate step: log rolls at cap into a real file chain; crash pile pruned newest-10;
priority-scope decision matrix. Gate 64/64.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:40:13 +01:00
EdnunpandClaude Fable 5 9a18d18656 Security phase 1: sealed remote control + service-folder lockdown + counter nonces
From the 2026-07-26 security audit, the two must-fix findings plus the crypto tidy:

1. Remote-control commands are now SEALED with the profile's audio key (AES-256-GCM,
   ControlSealing in Core). Previously a 2-byte plaintext payload gated only by a
   forgeable source-IP check - anyone who learned an allowed peer's address could
   drive the receiving machine's SYSTEM volume/mute, and muting a blind user's
   machine mutes their screen reader. Now only a password-holder can issue a command;
   a ControlReceiveGuard also bounds clock skew (10 min) and remembers nonces so a
   captured packet can't be replayed to re-toggle mute. Legacy plaintext control from
   pre-5.6 peers is dropped at the receiver (never acted on) - release notes must say
   both ends need 5.6 for remote volume.

2. Cross-user LPE closed: the SYSTEM service trusts app-source.txt to decide what to
   copy+run on self-update, and ProgramData lets any user who pre-created the service
   folder own it (CREATOR OWNER inheritance) and repoint that file. Elevated install
   now records the installing user's SID, takes ownership for Administrators and
   resets the ACL to exactly SYSTEM + Administrators + installing user (takeown +
   icacls /inheritance:r). Re-asserted on every SYSTEM self-update so existing
   installs pick it up; as SYSTEM with no recorded SID it defers rather than lock the
   user out of their no-admin workflow.

3. Audio-path GCM nonces are now counter-based per lane (random 4-byte prefix +
   64-bit counter, fresh sequence with every key rebuild) - unique by arithmetic,
   removing the random-nonce birthday bound on a long-lived key. Wire format
   unchanged; the receiver reads the nonce from the packet as before.

New gate steps: sealed-control auth/replay/stale/wrong-key/plaintext matrix + nonce
discipline; service-folder lockdown args + SID recording garbage-proofing.
Gate 63/63.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:34:49 +01:00
EdnunpandClaude Fable 5 6f91e0ed5e Peer extraction stage 3: peer cluster into MainForm.Peers.cs + heartbeat windows pinned
The god-object shrink, completed as pure code motion: the peer state (known/manual/
remembered/selected/named peers, endpoint anti-thrash) and the whole Peers section
(discovery reconciliation, endpoint follow, arming refresh, manual add, named-peers
dialog, remembered delete) move VERBATIM into MainForm.Peers.cs as a partial-class
split - the same pattern Andre's SensorReadout form uses. Same class, same members,
compiler-proven identical; MainForm.cs drops ~430 lines and the peer logic finally
has its own front door. The shared LOGIC under it (PeerArming, CaptureSpecBuilder,
PeerAddress) landed in stages 1-2.

Plus the last flagged test gap: the heartbeat Healthy->Stale->Unreachable windows are
now pinned against a CONTROLLED clock via a seam over the real derivation
(SnapshotHealthLocked, made static - it was already pure): 1s = Healthy, 3s = Stale,
6s = Unreachable, never-answered = pending first, Unreachable only after sustained
pinging. These are the numbers every peer's armed/pruned state hangs off in both the
app and the service.

Gate 61/61. This completes the review plan's Phase 4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:02:11 +01:00
EdnunpandClaude Fable 5 7d886ea910 Peer extraction stage 2: ONE send-spec builder for app + service (CaptureSpecBuilder)
The WASAPI outputs-or-apps spec assembly - the exact logic the standing 'service must
mirror the app's send behaviour' rule exists for - lived as two hand-mirrored copies in
MainForm.ApplySendSources and ServiceSendHost.BuildSendSpecs. Now ONE builder:
- Devices mode: loopback specs, 'Use Windows default' sentinel resolved to the LIVE
  default render endpoint, duplicates collapsed, resolved-default reported via out
  param (drives the app's re-route-on-default-change detection).
- Applications mode: one process-loopback spec per running PID per chosen app name.
The app layers WASAPI inputs + ASIO pairs on top; the service adds neither (by design).
Both callers rewired; behaviour identical by construction and pinned three ways: the
service parity test, the follower test (now also pinning the out param, follower+
explicit-device dedup to one spec, and not-running apps contributing nothing), and the
main-window profile round-trip.

Gate 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:51:21 +01:00
EdnunpandClaude Fable 5 1b7fb47cd3 Peer extraction stage 1: arming logic shared in Core (PeerArming)
The 'which selected peers do we actually stream to' rule lived as near-identical
private copies in MainForm.RefreshAudioReceivers and ServiceSendHost (prune peers
unreachable past the grace window - issue #8 - re-arm on recovery, change-detect via
an ordered signature). Now ONE Core home: PeerArming.ComputeArmedEndpoints with the
app's actively-receiving carve-out injected as a predicate (asymmetric-path streams
are never cut), plus the shared order-independent Signature. Both sides delegate;
behaviour identical by construction. Reachability test extended: carve-out honoured,
false predicate doesn't rescue a dead peer, signature order-free. Gate 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:48:41 +01:00
EdnunpandClaude Fable 5 e3bf28a414 P2 leftovers: every inline dialog now audited + one shared sample clamp (review complete bar peer extraction)
Inline-built dialogs were invisible to the accessibility audit - which is exactly how
mnemonic-less buttons kept slipping through (the single-instance dialog Andre caught,
and now TWO more found by this very change: ManualPeerPrompt's OK/Cancel and
ProfileSaveAsPrompt's Cancel had no mnemonics - both fixed). Each inline dialog's
construction is split from its ShowDialog into a Build seam, and the audit now covers
15 dialog surfaces (was 8): manual peer prompt, quick profile switch, change password,
password manager, profile name prompt, and the service Additional-options window join
the eight Form dialogs. Behaviour unchanged.

The encoder-boundary clamp is now ONE shared rule (Core SampleClamp) instead of three
private copies in MixingEngine / AsioCaptureBackend / PushModeWasapiBackend - and the
ASIO copy's up-to-four interlocked increments per frame became one batched add per
buffer on the RT thread. Pinned by a new self-test (over-range clamps to exactly +/-1
and counts; +/-1 exactly passes untouched).

Gate 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:38:40 +01:00
EdnunpandClaude Fable 5 e2211f8a40 Announce the outcome of a Delete in both remembered lists through the screen reader
Deleting a remembered app (or peer) was SILENT to NVDA: the next row lands on the same
list index the deleted one had and the list already has focus, so no focus/selection
event fires - nothing to announce (Ed, 2026-07-26: deleting foobar said nothing).

FocusAndAnnounceAfterDelete now follows the focus move with a Tolk line through the
existing ScreenReader channel (the house pattern for feedback the screen reader can't
observe): '<deleted row> removed. <row now under focus>.' - or that the list is empty.
No dialog, no popup; applied to the remembered applications AND remembered peers lists
so the two stay identical.

Gate 60/60.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:27:55 +01:00
EdnunpandClaude Fable 5 64c92a67bb ASIO switch: per-driver tick memory + fully async driver release (Ed's EVO->ReaRoute->EVO)
Diagnosis from Ed's 2026-07-26 desktop log: (1) the 'hang' was the EVO taking 5+s inside
its own Stop - the 8s bound caught it and unfroze, working as designed but still FELT;
(2) the silence returning to EVO was 18s of 'start requested but no sources configured' -
switching drivers rebuilds the pair list and clears ticks (deliberately: pair N is a
different physical channel on a different card), so nothing was ticked when he returned.

Fixes - completing the architecture rather than patching it:
- Per-driver tick memory: before the swap clears ticks, the outgoing driver's ticked
  pairs (send + receive) are remembered BY DRIVER NAME; returning to that driver
  restores its own ticks after the lists rebuild and re-applies audio, so it resumes
  by itself. Safety preserved: driver A's ticks never bleed onto driver B; pairs a
  smaller driver lacks are skipped. Snapshot/restore pinned by a new self-test.
- Async release: the UI thread no longer WAITS for the old driver to close at all.
  The old backend's callback is unhooked immediately (volatile write - it stops
  feeding the lanes before the new driver starts), then Stop/Dispose runs on a
  worker where the apartment's 8s bound still backstops a wedged driver. A slow
  close now costs nothing perceptually. Known accepted edge (documented): re-picking
  the SAME driver inside the close window can find the card still held; picking it
  again recovers.
- The once-per-second no-sources line is now transition-only (18 identical lines in
  Ed's log buried the signal).

Gate 60/60 incl. the ASIO-enabled churn: 52 transitions over the real Audient driver,
handles flat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:11:19 +01:00
EdnunpandClaude Fable 5 517a066923 Shared homes + legacy sweep (review Phase 4)
Duplication that had to agree by luck now has ONE home each:
- PeerAddress (Core): the host[:port] split + resolve-preferring-IPv4 that lived as
  byte-identical private copies in MainForm and ServiceSendHost (plus an extra resolve
  copy). Both now delegate. (The unicast-hints path deliberately keeps its own loop -
  it collects ALL IPv4 records, a different behaviour, not a duplicate.)
- RemSoundCrypto.ForPlainPassword: key + fingerprint always derived together - the
  divergence that once got the service's audio silently rejected can't recur.
- AudioTransportRules.EffectiveOpusFrameSamples moved to Core; the headless service no
  longer reaches into the WinForms MainForm for a frame-size rule.
- ServiceAudioDefaults (Core): the service's fixed live-jamming transport numbers,
  referenced by BOTH the dialog that writes the profile and the host that re-forces
  them at runtime - they can no longer disagree.

Legacy sweep:
- HandleTypeProbe + its per-minute tick retired (the 2026-06 handle-leak investigation
  it was built for is closed; git history has it).
- RemSound.Harness project removed from the tree + solution (superseded by --selftest).
- Fixed the stale AppConfig claim that per-profile hotkeys are 'no longer read' (the
  one-time v4.4 import reads them once); Profile.AudioPort now carries an explicit
  DEAD FIELD / trap warning backed by the persistence tripwire test.

Gate 59/59.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:17:41 +01:00
EdnunpandClaude Fable 5 9f13ef3aae Robustness: bounded ASIO close, native service self-update, rollback pinned (review Phase 3)
- AsioApartment gains a bounded Invoke; the close path uses it (8s cap - a healthy
  close is ~5ms). A driver that wedges inside Stop/Dispose can no longer hang a live
  driver-switch or the resume path: the caller logs the timeout and abandons the
  driver (old park semantics, reclaimed at process exit). Apartment test extended to
  pin the timeout path.
- Service self-update is now NATIVE code: a detached copy of the NEW build runs
  --service-selfupdate (stop -> CopyProgramTo -> start, every step logged to the
  update log). The old PowerShell restart script silently died wherever Group Policy
  enforces execution policy (Bypass is ignored there), stranding the service on the
  old build. Verb wired through IsServiceInvocation + ServiceEntry; verb-gate test
  now pins all six verbs.
- UpdateApplier SwapInNewFiles/RollBack made internal + pinned by a real-folder test:
  swap lands exactly the release's files (user files untouched), backup holds the
  originals, rollback restores BYTE-EXACT including deleting newly-created files -
  the contract that stops a failed update bricking an install.
- RouterPortMapper: discovery callback now checks disposed under the gate, so an
  in-flight callback can't re-open the port map Dispose just removed.

Gate 59/59.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:10:37 +01:00
EdnunpandClaude Fable 5 e356ee46dd Tests: persistence tripwire + cue resolution + audit/gate hardening, group 3 (review Phase 2)
- Profile persistence TRIPWIRE: reflection over every public writable Profile field -
  each must round-trip ApplyProfile->CopyTo or appear on an explicit declared list
  (control-owned or legacy, each with its reason). Add a field without wiring it and
  this FAILS naming the field - closing the three-parallel-lists drift trap for good.
  The declared list is itself checked against real properties so it can't rot.
  (Immediately proved itself: enumerated the 14 legacy v4.4 hotkey fields.)
- Cue variant resolution: 'Sound N' labels, numeric ordering, the drop-bare-when-
  numbered dedupe (the duplicate-row NVDA trap), chosen-default honoured + fallback
  when the chosen file is gone.
- Accessibility audit now includes UpdateInstallNoticeDialog (it was the one
  Form-derived dialog missing from the audit list).
- Skipped self-test steps are now LOUD: the summary names every skipped step and
  run-tests.ps1 prints a yellow warning - a gate pass with steps skipped no longer
  reads as full coverage.

Gate 58/58.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:04:38 +01:00
EdnunpandClaude Fable 5 354d2b4fdb Tests: receiver security gates + wire integrity, group 2 (review Phase 2)
Four more zero-coverage areas, all headless:
- PcmFrameAssembler: byte-exact in-order assembly; out-of-order/oversize/zero-part
  rejected AND counted; half-finished frames counted as discards; recovers after
  every failure. (A mis-assembly = audible corruption while packets still flow.)
- Receiver allow-list gate (SECURITY): a Format packet from a non-allowed sender is
  rejected + counted before any session opens; an allowed ADDRESS passes at any
  source port. Nothing previously proved the gate end-to-end.
- Receiver decryptor (SECURITY): wrong-password audio yields EMPTY (silence), never
  garbage; no key = everything drops (encryption is mandatory); right key round-trips
  byte-exact.
- Heartbeat: payload wire format round-trips, and a ping injected into the service
  produces a pong back to the SOURCE endpoint with the originator tick unchanged -
  the number RTT (and so every peer's Healthy/Unreachable state) is computed from.

Receiver gains the same InternalsVisibleTo(RemSound) bridge Sender/Core already had.
Gate 56/56.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:57:15 +01:00
EdnunpandClaude Fable 5 9627f320ca Tests: audio-correctness + infra pinning, group 1 (review Phase 2)
Five subsystems that had ZERO coverage - all pure code where a real bug would ship
green because the flow tests only prove packets move, not that bytes are right:
- AudioRingBuffer: wrap correctness, overflow drops OLDEST, DropOldest/TrimFromProducer
  semantics, silence-fill + exact underrun/drop counters, oversized-write degrade.
- PcmPack 24-bit codec: +/-1-LSB round-trip incl. negatives (a sign-extension bug =
  distortion on the whole PCM path), sign preservation, over-range clamps not wraps.
- RemSoundUpdater tag filtering: server-v* relay tags rejected, numeric ordering
  (v5.10 > v5.9), suffix tolerance - what stops a wrong-version auto-update.
- RouterPortMapper.IsCgnatAddress: RFC 6598 boundaries exact (made internal for test).
- NetworkPriority.TryAttach: no-throw smoke on a bound socket.

Gate 52/52.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:51:57 +01:00
Ednunp 527c2976c9 Repo hygiene: remove the stale 'pi server' copy (v2.3; 'server' at v2.4 is canonical - a fix cut from the stale copy would revert the forged-BYE security fix), rescue its two unique design docs into server/, and gitignore the untracked personal pi-sender folder 2026-07-23 15:47:38 +01:00
Ednunp 81d19c7b8c Discovery: optional display-name override for headless senders (announce a friendly name instead of the hostname; null keeps the machine name, so the app is unchanged) 2026-07-23 15:47:38 +01:00
EdnunpandClaude Fable 5 b010525bce Review bug batch: Delete key on remembered apps (#26), phantom service cues, suppress-flag guards
- Remembered applications list now supports the Delete key (issue #26): deleting an
  entry forgets it machine-wide, mirroring the remembered-peers list exactly (same
  focus-next-item behaviour). Logic lives in RemoveRememberedApplication so the
  self-test drives the real path; the send-app semantics test now covers deletion.
  Manual documents Delete on BOTH remembered lists (neither was documented).
- Service dialog's Additional options no longer offers connect/disconnect cue
  checkboxes: the headless service never plays cues (nothing in it touches CuePlayer,
  and a logged-out session couldn't render them), so the switches did nothing.
  Manual sentence updated to match.
- Three re-entrancy suppress flags (send-mode reset x2, streaming password gate) now
  clear in try/finally - a throw can no longer leave list events suppressed for the
  rest of the session.

Gate 47/47.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:47:38 +01:00
EdnunpandClaude Fable 5 cf1eb92f11 Fix the two remaining UI-thread elevation freezes (profile save + installer)
Review finding, same class as the shipped install-hang fix: two paths still ran the
elevated service helper synchronously on the UI thread, freezing the window and live
audio for the duration (worst case minutes across two UAC prompts).

1) Service-profile save (MainForm.ConfigureServiceProfile): saving while the service
   runs did RunElevated(stop) + RunElevated(start) inline. Now: the restart runs on a
   background task, and tries a NO-UAC restart first - ServiceControl.TryRestartNoAdmin
   uses the start/stop rights the installer grants the installing account, so the
   normal case has no elevation prompt at all. Elevated verbs remain the fallback
   (service installed by a different account). Success is silent; only a failed
   restart reports back. The save popup now says the service is restarting.

2) App installer's optional service step (AppInstaller): the install + start-now calls
   ran RunElevated inline. The flow is sequential (can't fire-and-forget - the installer
   relaunches and exits afterwards), so RunElevatedResponsive runs the helper on a
   worker while a small modal "working..." shell pumps messages: UI and audio stay
   live, nothing can be double-triggered, NVDA announces the step, and the exit code
   still returns inline.

Test: "No-admin service restart fails safe" - TryRestartNoAdmin against a missing
service returns false promptly without throwing (that false routes callers onto the
elevated fallback). The success path needs the real SCM + grant, covered by hand-test.
Gate 47/47.

Part of the review-fix batch; no release until the whole plan lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:40:30 +01:00
EdnunpandClaude Opus 4.8 97c96b4fcb Release prep v5.5: version bump, About changelog, release notes
- csproj <Version> 5.4 -> 5.5.
- About box: prepend the v5.5 entry (UPnP close-hang fix + keyboard shortcuts on
  every dialog).
- RELEASE_NOTES.md rewritten for v5.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:39:46 +01:00
Ednunp a3b8f1659b UPnP: breadcrumb before the router teardown calls so a hang names itself in the log 2026-07-18 09:21:58 +01:00
EdnunpandClaude Opus 4.8 8fe52047be Fix UPnP close-hang, and add mnemonics to every dialog's buttons (Andre)
UPnP close hang:
- RouterPortMapper.Stop()/Dispose() does a SYNCHRONOUS DeletePortMap call to the router
  (plus StopDiscovery). When the router is slow or unresponsive that blocks — and it ran on
  the UI thread in FormClosing, so enabling UPnP could make RemSound impossible to close
  (Andre, this morning; his logs show UPnP teardown taking several seconds even on a good
  run). Moved the router teardown into the same bounded background-task pattern already used
  for the ASIO audio dispose: UPnP + audio now tear down off the UI thread, in parallel, under
  one 3s cap. Anything unfinished is reclaimed on process exit, so the window always closes.

Mnemonics on dialog buttons:
- Andre flagged the "RemSound is already running" dialog (single-instance) having no shortcut
  keys. Its three TaskDialog buttons had no & mnemonics; added Alt+S / Alt+F / Alt+C.
- Swept every dialog. Custom TaskDialog buttons and Form OK/Cancel/Close buttons that lacked
  mnemonics now have them: single-instance (Switch/Force/Cancel), save-onto-read-only
  (Save/Cancel), Add EQ band, Change/Manage profile password, Quick profile switch (Close),
  Rename peer (OK), About (Close). Cancel stays on Esc in the few dialogs where Alt+C is
  already taken (Rename peer's Clear, Service profile's peers list) — by design, not a miss.
- The rest already had mnemonics (Keyboard-shortcut import, Update-install notice, Profile
  selection, Recording settings). The dialog-accessibility gate (mnemonic uniqueness + names)
  stays green, so nothing collides.

Gate 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:17:49 +01:00
Ednunp a433645fd4 Refresh MANUAL.md from readme.html for v5.4 2026-07-18 00:20:09 +01:00
EdnunpandClaude Opus 4.8 44883af542 Release prep v5.4: version bump, About changelog, release notes
- csproj <Version> 5.3 -> 5.4.
- About box: prepend the v5.4 entry (Use Windows default output + exclusivity, the
  service-install freeze fix + start-after-install, service now outputs/apps only).
- RELEASE_NOTES.md rewritten for v5.4.

(The manual, readme.html, was already updated incrementally as each change landed.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:19:58 +01:00
EdnunpandClaude Opus 4.8 27353a3f41 Service: drop the WASAPI inputs (mic) list — outputs and apps only
The service streams a machine's OUTPUT audio (or specific apps); capturing a mic from an
unattended, logged-out box isn't a use case (Ed). Removed the "WASAPI audio inputs to
send" list from the service config dialog, and made the service ignore inputs everywhere:

- ServiceProfileDialog: removed the inputs CheckedListBox, its status label, its row and
  accessibility wiring. Save now clears SelectedWasapiSendInputs so a profile saved by an
  older build can't keep a mic streaming.
- ServiceSendHost.BuildSendSpecs: removed the input-spec loop — a legacy profile's inputs
  are deliberately ignored.

(The main app is unchanged: it still has its WASAPI inputs list and AudioDeviceCatalog
.LoadInputs stays for it.)

Test: the service-parity test now adds a mic to the profile and asserts BuildSendSpecs
emits only per-application specs and never a CaptureKind.Input. Gate 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:10:36 +01:00
EdnunpandClaude Opus 4.8 0102aa51f4 Service: offer to start it right after installing (uninstall already stops first)
- After a successful install, both entry points now ask "start it now?" (Yes/No):
  the app's Service menu (RunServiceVerbAsync reused for the follow-up start) and the
  app installer's optional service step. Without this the service only comes up at the
  next boot, so a first-time user saw nothing happen after installing it.
- Uninstall already stops the service before deleting (DoUninstall -> DoStop, which
  waits up to 15s for Stopped), so nothing to change there - confirmed, not added.
- Manual: note the start-now prompt in the service section.

No new automated test: these are UI Yes/No prompts layered on the already-tested
start/stop verbs. Gate 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 00:02:04 +01:00
EdnunpandClaude Opus 4.8 aa5707f8e1 Fix the service install hang (pipe deadlock) + stop it from ever freezing the app
Ed uninstalled then reinstalled the service from the app and the app "kind of crashed":
its audio froze while the connection stayed alive. The logs told the whole story - the
app logged "install requested" then never "install finished"; the elevated helper
(child of the app) was still running 40+ minutes later, and the app sat blocked on it,
UI thread frozen, so streaming died but the background heartbeat kept ticking.

Root cause - a classic pipe deadlock in the elevated installer. GrantUsersWriteToBin ran
`icacls /T` over the service bin (100+ files) and read STDERR to end, THEN stdout. icacls
floods stdout far past the ~4 KB pipe buffer, so it blocked writing stdout while we blocked
reading stderr - forever. That hung DoInstall, which hung the app waiting on it.

Fixes (root cause + defence in depth, so a stuck helper can never freeze the app again):
- RunProcessCaptured: one safe process runner that drains stdout AND stderr concurrently
  (async), bounded by a timeout, and kills the child (whole tree) if it overruns. RunSc,
  RunScCapture and GrantUsersWriteToBin all go through it now. This kills the deadlock.
- RunElevated now waits with a 120s cap and returns ElevatedTimedOut instead of blocking
  forever.
- ServiceAction runs the elevated helper OFF the UI thread and reports the result back, so
  even a slow/stuck helper can't stall the window or its audio. New "timed out" message.
- Program.cs Environment.Exit()s after a one-shot service verb, so a helper that finished
  its work can never linger (non-background thread) with the app waiting on it.

Test: "Elevated helper: no pipe deadlock on flooded output" - RunProcessCaptured against a
child that floods both pipes with ~260 KB (a big dir listing + a failing dir); it must
return promptly with the full output. The pre-fix order would have hung. Gate 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:56:26 +01:00
EdnunpandClaude Opus 4.8 5fc1b5bd75 "Use Windows default" is now exclusive: it clears + locks out the specific cards
Ed: ticking "Use Windows default audio device" should uncheck the other cards and stop
them being ticked again until the default is turned back off - in both the app and the
service. This replaces the old optional "shall I untick the others?" prompt with a hard
rule.

Behaviour (all follower lists: app received-outputs, outputs-to-send and inputs-to-send;
service outputs):
- Ticking the follower unticks every specific card in that list.
- While the follower is on, trying to tick a specific card is refused (the ItemCheck is
  vetoed straight back to unticked) - you're either following the Windows default or
  picking cards, never both.
- Unticking the follower frees the cards again.
- Enforced on load too, so a hand-edited/legacy profile carrying both comes up clean.

Shared, not duplicated: the veto + clear + follower-checked helpers live in
AudioDefaultFollower and are used by MainForm and the service dialog alike.

Removed the now-obsolete soft prompt and everything that hung off it:
- MainForm.MaybeUntickOthersForDefault + ResetDefaultAudioDevicePrompt.
- The "Reset the default audio device prompt" Options item (and its Alt+O,R).
- AppConfig.UntickOthersWhenUsingDefaultOutput/Input.
- Manual + About-box references to the prompt / reset item; the manual now documents
  the exclusive rule and lists all three follower lists (the outputs-to-send follower
  had been undocumented).

Test: "Default follower exclusivity" self-test - follower on clears + locks specific
cards, follower off frees them, unticking and the follower entry itself are never
vetoed. Gate 45/45.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:26:38 +01:00
EdnunpandClaude Opus 4.8 4a86a82ec8 Service: offer "Use Windows default output" (Christopher's request), reusing the app's follower
The service profile could only pick named output devices, so it couldn't "just send
whatever this machine plays and follow the Windows default". The main app already had
exactly that (its DefaultLoopbackSendFollower + ResolveDefaultDeviceId) - so rather
than invent a parallel mechanism (which would silently diverge), this pulls the shared
piece out and has the service reuse it.

- New AudioDefaultFollower: one home for the loopback-send sentinel
  ("__use-default-loopback-send__"), the follower list entry, and the default-endpoint
  resolver. MainForm now references it (its DefaultLoopbackSendFollower and
  ResolveDefaultDeviceId delegate to it) so there is a single definition.
- Service config dialog: the Audio send tab lists "Use Windows default audio device,
  follows Windows changes" as the first output choice. Ticking it persists the same
  sentinel the app uses.
- ServiceSendHost.BuildSendSpecs resolves that sentinel to the LIVE default render
  endpoint (with de-dup against explicitly-ticked devices), never passing the raw
  sentinel through. Because the service re-applies its profile on every device-change
  notification - and OnDefaultDeviceChanged is one of them - it FOLLOWS the default:
  change Windows' default output and the service switches to it within a beat.
- Manual: documents the new option in the service section.

Test: new "Default-output follower" self-test - follower is flagged + shares the app's
sentinel, and the service resolves it to the current Windows default render endpoint
(never leaks the raw sentinel into a capture spec). Gate 44/44.

The ASIO "Rea" devices Ed noticed are real registry drivers (Realtek ASIO + REAPER's
ReaRoute), not injected dummies - we only ever list HKLM\SOFTWARE\ASIO. No code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:08:55 +01:00
Ednunp 43e249e90e Refresh MANUAL.md from readme.html for v5.3 2026-07-17 17:33:03 +01:00
EdnunpandClaude Opus 4.8 0f3e5e3bfc Release prep v5.3: version bump, About changelog, release notes
- csproj <Version> 5.2 -> 5.3.
- About box: prepend the v5.3 entry (single-app sending, lock-screen service,
  freer ASIO driver switching).
- RELEASE_NOTES.md rewritten for v5.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:32:40 +01:00
EdnunpandClaude Opus 4.8 0db36f278d Service: remove the "send all applications" option (finish what the main app did)
Ed asked back on 2026-07-16 to drop "send all applications" from the app AND the
service - applications mode = pick specific apps only. The main app was done; the
service kept it. Worse, a code comment had pinned it in place as a DELIBERATE
divergence ("the headless service streams system audio, so send-all stays"), which
is exactly why the earlier removal skipped the service. Standing rule bit us: the
service silently diverged from the main app.

Removed across the whole service path, not just the checkbox:
- ServiceProfileDialog: dropped the "Send all applications" AccessibleCheckBox, its
  event wiring and visibility branch; applications mode now always shows the specific-
  apps list. Renumbered the tab's Alt hints (apps Alt+3, inputs Alt+4) now the box is
  gone. Save forces SendAllApplications=false so a stale profile can't resurrect it.
- ServiceSendHost.BuildSendSpecs: deleted the SendAllApplications branch (and the now-
  dead ResolveDefaultRenderId helper). Apps mode builds one process-loopback spec per
  ticked app - identical to the main app.
- Profile.SendAllApplications: marked vestigial (kept only so old profiles deserialize;
  nothing reads it now).

Guard so it can't drift again: the Service-sender-parity self-test now asserts apps
mode produces only per-application specs and NEVER a whole-system loopback spec, even
with SendAllApplications=true. (The old parity test only exercised devices mode, which
is why it missed this.)

Service UI audit (Ed's question): the whole service UI is house-controls throughout -
QuietTabControl, AccessibleCheckBox, MnemonicLabel, Theme.SectionHeader/AppIcon,
FormLayoutRows, CheckedListAccessibility. No raw CheckBox/TabControl (which are silent
to NVDA on .NET 10) anywhere in Service*.cs.

Gate 43/43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:20:47 +01:00
EdnunpandClaude Opus 4.8 7135908339 ASIO: correct stale close-lifecycle comment after the apartment change
UpdateSources still claimed the driver "only truly closes on Stop()/Dispose()".
That stopped being true when the apartment made closing safe: the driver now also
closes on ASIO-deselect and driver-switch (AudioSender releases it). Comment-only,
no behaviour or IL change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:13:45 +01:00
EdnunpandClaude Opus 4.8 7b1eac8df4 ASIO: full lifecycle breadcrumbs so a crash (or clean run) is legible in the log
If the new apartment ever DOES still crash on Ed's Audient hardware, the crash is a
native access violation that leaves no managed stack - so the only way to see where
it died is a breadcrumb written to disk immediately before each native call. The log
already runs with AutoFlush on, so each line is on disk before the next native call.

The close path already logged step-by-step (unhook / stop / dispose / released). This
makes the OPEN path symmetric and gives the apartment thread its own voice - all
through the normal gated Event() sink (no gate bypass; logging stays off when the user
has it off):

- Open: "asio open: creating driver" -> "init record+playback" -> "starting stream
  (play)" -> "stream running". A native death names the exact stage.
- Apartment: "thread up (STA, managed id N)" on construct; "thread down (clean)" or
  "thread did not join in 2s (leaked)" on dispose - confirms the dedicated STA thread
  actually came up and tore down.

So a clean run reads: apartment up -> open steps -> [use] -> close steps -> released
cleanly -> apartment down. A crash truncates at the exact native call that failed.

Gate 43/43 (ASIO churn over the real Audient driver, 52 open/close transitions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 17:00:58 +01:00
EdnunpandClaude Opus 4.8 3d4d5af326 ASIO: own the whole driver lifecycle on one pumped STA thread; free the card when ASIO is deselected
The problem: we stopped ever closing the ASIO driver because closing it could
crash the process natively (Audient threw an access violation on Dispose with no
managed stack). Keeping it open dodged the crash but held the sound card
exclusively for the whole time RemSound was running - so no second ASIO driver
could be used, and no other app could touch that card, even when RemSound wasn't
playing through it.

The cause: ASIO drivers are COM objects that want every control call (create /
init / start / stop / dispose) on ONE thread, with a live message pump to service
the messages the driver posts during init/reset/close. We were calling them from
whatever thread hit Start/Stop, with only a Sleep() before the close - so the
close ran with no pump and often on the wrong thread, and took the process down.

The fix: AsioApartment - a dedicated background STA thread running a real Windows
message pump. Every AsioOut control call in AsioCaptureBackend now goes through
apartment.Invoke(...): the create/init/play in Start, and the unhook/Sleep/Stop/
Dispose in StopInternal. The driver gets one stable home thread and a live pump,
so it can be closed cleanly. The real-time audio callback is untouched - it still
runs on the driver's own thread.

Because closing is safe again, EnsurePersistentAsioLocked now RELEASES the driver
(Dispose + null) when ASIO is deselected, instead of parking it open. The composite
backend borrows the persistent ASIO but never disposes it, so releasing here is the
single owner freeing the card - which lets another ASIO driver (or another app) use
the card once RemSound is off ASIO.

Validated: the ASIO-enabled gate churned the real Audient driver through 52
open/close transitions with no crash and bounded handles (+27). Live hardware
streaming still needs Ed's confirmation.

Test: "ASIO apartment thread" self-test asserts work runs on one dedicated STA
thread (not the caller's), exceptions propagate to the caller, and the apartment
survives a work item throwing. Gate 43/43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:54:12 +01:00
EdnunpandClaude Opus 4.8 d002130402 Cleanup + security narrowing + manual updates (post-review)
Security (narrow the no-admin grants to one account):
- The service's no-admin start/stop and bin-write grants went to Authenticated Users /
  BUILTIN\Users - together that was a one-step local privilege escalation for ANY
  account (overwrite the SYSTEM-run binary, then stop/start it). Now both grants go to
  the INSTALLING user's SID only (the elevated install runs as that interactive user).
  Same effortless workflow for that user; the any-account escalation surface is gone.
  AddUserStartStopAce takes the SID; self-test asserts it's scoped, not AU.

Dead-code removal:
- --probe-apploopback diagnostic verb + ProbeAppLoopback.cs + the ProcessLoopbackCapture
  .Diagnostic hook (all scaffolding for the now-fixed activation bug).
- --update-service verb + ServiceControl.DoUpdate (the "Update service" menu item is
  gone; auto-update via ServiceUpdate.RestartSelf replaced it). Verb gate now lists five.

Manual (readme.html):
- New "Sending specific applications" section (the How-to-send chooser + the two app
  lists) and the Alt+6/8/9 shortcuts - the whole per-app feature was undocumented.
- Documented the two "Clear remembered ... list" buttons on Preferences > General.

Gate: 42/42.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:37:13 +01:00
EdnunpandClaude Opus 4.8 984bcd042e Review sweep: fix real bugs found across the service + per-app + settings changes
Parallel code review of this session's changes surfaced several real bugs; fixed the
substantive ones (judgment/cleanup calls held for Ed):

- HIGH Clearing "remembered peers" was resurrected on the next launch: the per-profile
  -> global migration re-ran every startup and re-unioned the profile file's stale copy.
  Added a one-time AppConfig.RememberedPeersMigrated marker so migration runs once and a
  cleared list stays cleared. Self-test pins the clear-then-reload scenario.
- MED PushModeWasapiBackend.Start rethrew on a device-open failure; nothing up the stack
  wraps it, so device churn (a push-eligible single WASAPI source unplugged mid-open)
  could crash the app. Now logs and stays stopped like MixingEngine/ASIO; the device
  watcher / self-heal re-open when a device returns.
- MED Service self-heal: (a) the re-open "no send sources" path left PerformanceMode ON
  and presence up while streaming nothing - now releases cleanly; (b) the 3-attempt
  ladder never refunded, so 3 hiccups over a days-long stint meant permanent silence -
  now refunds when real audio is heard, and resets on a device hot-plug.
- MED ApplyProfile resolved peers (DNS) and enumerated devices INSIDE the gate lock -
  a boot-time DNS hang as SYSTEM stalled Suspend()/yield/self-heal. Moved outside the lock.
- LOW AudioSessionStartWatcher leaked the AudioSessionManager on every Rehook (the WASAPI
  handle-leak fingerprint) - now disposed. New lifecycle self-test.
- LOW stale docstrings (send-all master toggle; ServiceUpdate in-place scheme; Profile
  .SendAllApplications "neither reads nor writes").

Gate: 42/42 (added peers-migration + session-watcher-lifecycle tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:49:01 +01:00
EdnunpandClaude Opus 4.8 4b2baa58b5 Service install: fix the bin-folder write grant (icacls) so it actually applies
The first icacls grant did not take (bin files stayed Users:RX), so a stopped service
still could not be updated without admin. Grant BUILTIN\Users (SID *S-1-5-32-545,
locale-independent) Modify with (OI)(CI)(M) and /T over existing contents, and log
icacls stderr on failure instead of swallowing it. Matching fix in the dev bootstrap
script.

Gate: 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:17:40 +01:00
EdnunpandClaude Opus 4.8 5dfc4faec6 Service: remove the confusing Update-service menu item; make bin folder user-writable
Ed: a menu item a user has to know when to click is the wrong answer -- real users get
the service via the automatic self-update (version bump), full stop. Remove the menu item.

For developer/tester same-version refreshes, honour "stop it and update it": install now
grants Authenticated Users Modify on the service bin folder (icacls), so once the service
is stopped its binaries can be replaced with no admin -- the exact workflow Ed asked for.
Same trust posture as the auto-update copy (user-writable content run as SYSTEM); noted
for a future code-signed hardening.

The --update-service verb / DoUpdate stay as internal plumbing (no user-facing entry).

Gate: 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:09:48 +01:00
EdnunpandClaude Opus 4.8 9e75331daf Service auto-updates itself when the app updates (no UAC, no menu click)
The self-contained-service change broke the old self-update: the service used to run in
the app's own folder and restart onto a newer RemSound.exe that landed "next to it";
now it runs from its own ProgramData copy, so it never saw the app's new build.

Rework: at install/update the service records the app's folder (ServiceStore app-source
path, written elevated so the SYSTEM account can read it). The service's existing 45s
update poll now watches THAT folder; when the app's auto-updater drops a strictly-newer
RemSound.exe there, the service (as SYSTEM) copies the new build into its own bin and
restarts onto it via the detached restarter script. All SYSTEM-side: no UAC, no user
action. Loop-safe (strictly-newer only; bin == app version after the copy).

So a real release (version bump) propagates to the service automatically. Same-version
dev rebuilds don't trip the strictly-newer check -- the Service menu "Update service to
this version" forces those.

Trust posture unchanged from the old in-place scheme (SYSTEM copies from a user-writable
folder); noted in the class doc for a future code-signed hardening.

New self-test coverage: app-source path round-trip + "no readable app version => no
update" (never act on uncertainty). Gate: 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:03:37 +01:00
EdnunpandClaude Opus 4.8 fa554cbe35 Service: one-click Update-service-to-this-version (no uninstall/reinstall)
Because the service now runs from its own admin-only copy, getting a new build into it
meant uninstall+reinstall (two UAC prompts). Add a DoUpdate path + Service-menu item
that does it in one: stop -> overwrite the ProgramData bin copy from the running app ->
start. Wired as the --update-service verb (elevated), recognised by the service-verb
gate. Enabled only when the service is installed; falls back to a plain install if not.

Note: the service bin folder stays admin-only writable on purpose (a user-writable
SYSTEM binary would be a privilege-escalation hole), so the copy is done elevated via
the one UAC prompt rather than in-process.

Gate: 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:55:14 +01:00
EdnunpandClaude Opus 4.8 b8d0fa5a65 Service: start capturing immediately at boot (catch the Windows startup sound)
The boot log showed a 2.6s gap between the service OnStart and its first capture --
that is the resume-settle delay (2000ms), which exists to stop rapid app open/close
from thrashing the engine. But at boot the interactive app has NEVER been present, so
waiting it out is pure dead time in which the Windows startup tune plays uncaptured
(NVDA, which keeps talking, was caught once capture finally came up; the one-shot tune
was missed).

Fix: the settle now applies only AFTER the app has actually been present (a real
yield). The first-ever stint at boot starts capture immediately, ~2.6s sooner, giving
the startup sound a chance to be captured. Later app->absent transitions keep the
anti-thrash settle.

Gate: 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:48:30 +01:00
EdnunpandClaude Opus 4.8 4e15451e1d Service owns its binaries + is stoppable without admin; installer offers the service
The service was registered to run from wherever it was installed FROM (Environment
.ProcessPath), so installing from a dev/test folder pinned it there: it locked those
files (blocking every rebuild) and, for a real user installing from the app folder,
would lock the app's own binaries and break the auto-updater. Stopping it also needed
admin, so only the app's UAC-prompting Service menu could do it.

Fixes:
- The service now installs its OWN copy of the program into ProgramData\RemSound\
  service\bin and is registered to run from there. Never touches the install/dev
  folder again. CopyProgramTo copies the exe + DLLs + runtimes + default sounds but
  excludes user-state folders; uninstall removes the bin copy.
- Install grants Authenticated Users start/stop/query on the service (sc sdset, ACE
  merged into the existing DACL) so it can be stopped with a plain `sc stop
  RemSoundService` or the Service menu -- no admin, no app. Pure SDDL-amend helper is
  unit-tested (placement + idempotency).
- The app installer now asks, after install, whether to also install the service
  (optional, its own UAC step; declining is fine -- Service menu installs it later).
- deploy-test.ps1: only a copy running FROM the publish folder locks its binaries, so
  only that forces a sounds-only deploy. The service (ProgramData) and an installed app
  no longer make the script silently skip the binary publish -- the bug that had us
  testing stale builds for rounds.

New self-test "Service self-contained install" (runs-from-own-bin, SDDL amend, copy
exclusions). Gate: 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:38:24 +01:00
EdnunpandClaude Opus 4.8 a450cae66a Lock-to-clock always on (remove option); service audio hardcoded to Opus live config
Ed, 2026-07-17. The bigger half of the service crackle fix: raw PCM sounded hideous;
Opus at the live-jamming frame with Small packets and lock-to-clock sounds right.

Lock to audio clock (TightLatencyMode) is now ALWAYS ON and no longer a user option:
- Removed the checkbox from the main window and the service dialog. Nobody ever runs
  with it off -- off just adds delay.
- Main app forces the sender tight at startup; Profile.TightLatencyMode defaults true
  and is kept only for file compatibility. Load/SaveTightLatencyMode accessors removed.

Service audio profile is now fixed and unconfigurable:
- Removed the "Audio profile" tab from the service config dialog entirely (two tabs
  left: Connectivity, Audio send).
- The service FORCES Opus + 2.5 ms frame (120 samples) + Small packets + lock-to-clock
  at runtime in ServiceSendHost.ApplyProfile, ignoring whatever the profile carries, so
  a stale/hand-edited profile can never put it back on a bad codec. SaveToProfile writes
  the same fixed values.

Robustness: ApplyProfile now swallows a capture Start() failure (with lock-to-clock
always on the WASAPI lane is push-mode, which opens the device synchronously and throws
if it is invalid/gone) -- presence stays up and the device-watcher/self-heal re-open when
a device is available, instead of the exception crashing the service loop.

Self-test "Service sender parity" rewritten to prove the service overrides a PCM profile
to Opus/120. Gate: 39/39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:38:29 +01:00
EdnunpandClaude Opus 4.8 ec149ed09e Service crackle fix: un-throttle the process while streaming (PerformanceMode)
The send-only service sounded hideous/crackly vs the main app, even standalone, with
clean capture (capPeak ~0.44) and clean receiver metrics (no underruns/drops/gaps) --
the fingerprint of a process being starved by the OS scheduler, not a data problem.

Cause: a headless Windows SERVICE is a background process, so Windows aggressively
downclocks it (EcoQoS), migrates its threads onto efficiency cores, and gives it a
coarse scheduler quantum -- exactly what starves the audio send loop into crackling.
The interactive app engages PerformanceMode only on the user high-priority toggle;
the service never engaged it at all, and being a service it is throttled far harder.

Fix: the service now engages PerformanceMode (EcoQoS opt-out, High priority, 1ms timer,
no deep C-states, working-set floor) whenever it is streaming, released on suspend/
dispose. Unconditional -- the service has no foreground/battery use case; it exists
solely to stream and must not be throttled.

Gate: 39/39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 12:13:29 +01:00
EdnunpandClaude Opus 4.8 cd9760d7d5 Fix the real per-app capture bug: async completion was never delivered
Root cause (proven with a focused probe, not guessed): per-app process-loopback
capture has NEVER worked on any machine. ActivateAudioInterfaceAsync returned S_OK
but the completion callback was never delivered, so activation always timed out
after ~3s and the app captured nothing. The earlier "InvalidCastException /
E_NOINTERFACE" was the same defect wearing a different mask (the out-param cast
throwing at the synchronous return), and the old lifecycle self-test passed green
only because it checked for an exception a hair before the timeout surfaced.

Two things were wrong, both in the completion path:
1. IActivateAudioInterfaceCompletionHandler.ActivateCompleted declared its operation
   parameter as the typed IActivateAudioInterfaceAsyncOperation. The CLR
   QueryInterface'd that operation as the call was delivered; the QI returns
   E_NOINTERFACE, and it failed INSIDE the interop stub -- before our method body --
   so the whole callback was silently dropped. Fix: take the operation as a raw
   IntPtr so the stub marshals nothing and the method actually runs.
2. Reading the result by casting the operation pointer to the managed interface hit
   the same failing QI. Fix: call GetActivateResult through the vtable directly
   (slot 3), since the pointer already IS that interface per the API contract.

Verified on real hardware via a new hidden --probe-apploopback diagnostic verb:
process-loopback now activates in ~2ms and captures real audio from a specific app
(18 MB from Chrome). Lifecycle self-test now activates in <1ms (was hanging ~2s)
and asserts activation is both clean AND prompt -- the timing check is what finally
caught this; the old test could not.

Also tried and discarded (kept out of the tree): a CoWaitForMultipleObjects
dispatch wait, an IAgileObject agility marker, and a retry loop -- none were needed
once the param/vtable fix landed. Removed to avoid confusion.

Gate: 39/39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:45:41 +01:00
EdnunpandClaude Opus 4.8 4d49d5554e Issue #23: instant session-kick self-heal — re-attach BEFORE the first note, no polling
Ed: ~1s was still too slow; the capture must hear the boot audio from the instant
it starts — and no hot check-check-check loop.

Event-driven instant path: hook the Windows session-created notification on the
default render device (the same AudioSessionStartWatcher that catches a per-app
send from its very start). An app setting up an audio session fires it BEFORE its
first sound plays — at the boot lock screen that's LogonUI / the Windows tune /
NVDA arriving. New session + this capture has never heard audio -> re-open right
then, so the re-attached capture is listening from the first note. Zero polling.

- CRITICAL filter: our own silence keepalive also creates a session on that device
  — reacting to our own pid would re-open in an endless loop. Filtered.
- Re-open is now capture-only (sender stop -> rebuild specs -> start): network
  presence stays up, peers see no discovery blip. Shared by both self-heal paths
  (instant session-kick + the 500ms meter watchdog, which stays as the backstop in
  case the session notification doesn't cross sessions pre-login) and rate-limited
  + capped in ONE place so the two paths can't stack re-opens.
- Callback hops to the thread pool — never tears down audio objects from inside an
  audio notification.
- Watcher recreated per apply (re-points at the current default device), disposed
  on suspend/dispose.

Gate: 39/39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:27:35 +01:00
EdnunpandClaude Opus 4.8 950a61cbf3 Issue #23: deaf-capture detector via endpoint meter — react in ~1s, not 15s
The 15s silence-pulse trigger was far too slow (Ed: the boot tune would be over
before the first check) and silence alone was always a weak signal — a quiet
machine and a deaf capture look identical from inside the stream.

New detector: read the endpoint's OWN output meter (IAudioMeterInformation
.MasterPeakValue) every watch tick, independently of our capture stream, and
compare it with what the capture is hearing. Device audibly playing (meter >=
0.003) while the capture has heard only silence since it opened = the capture is
provably DEAF -> re-open it immediately so it re-attaches to the live audio graph.

- Service loop tick 1000ms -> 500ms; deafness threshold is TIME-based (450ms of
  continuous divergence) so reaction lands ~1s after the first audible sound —
  fast enough that the boot tune itself comes through — and a fast test cadence
  can't trip it (a healthy capture hears real sound within ~200ms).
- Zero churn risk: a quiet machine reads quiet on BOTH sides, so healthy captures
  never re-open (the old design would have re-opened 3x on any quiet stint).
- Frozen callbacks (2s+) still re-open regardless of loudness.
- Ladder: max 3 re-opens per stint, 2s spacing, ends at the first real audio
  heard; refilled on Resume and power resume.
- Meter readers swapped per (re)apply, disposed on suspend; per-device catch
  absorbs a disposed/invalidated endpoint mid-read.
- 15s pulse is now purely diagnostic and logs capPeak + meterPeak maxima with an
  explicit "(DEVICE AUDIBLE BUT CAPTURE SILENT)" flag.
- Decision core (ShouldReopenCapture) pure + pinned by updated self-test.

Gate: 39/39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:21:54 +01:00
EdnunpandClaude Opus 4.8 c72169c7f5 Issue #23: boot lock-screen silent-capture self-heal (re-open ladder)
Corrected diagnosis (Ed: the machine's OWN speakers audibly play the Windows tune
and NVDA at the boot lock screen — the endpoint is NOT silent): a loopback capture
attached in the first seconds of boot can land on an audio-engine mix the
logon-session audio path was never wired into. Callbacks flow (fed by our own
silence keepalive) but carry none of the audio that is audibly playing, and Windows
fires no device event about it — the previously-shipped device-change reopen never
triggers. Signing in re-plumbs the session audio into the graph, which is why sound
starts instantly with zero change on our side; a capture opened after sign-out
(graph fully live) works at the lock screen, matching Jonathan's reports exactly.

Fix: while sending, the 15s capture pulse now drives a self-heal — if the capture
has heard only silence since it opened (pre-encode peak < 0.001 on every pulse), or
its callbacks freeze, tear down and re-open the capture so it re-attaches to the
live graph. Capped at 3 attempts per sending stint; the first real audio ends the
ladder so a quiet-but-healthy capture is never churned. Ladder refills on Resume()
and on power resume (wake re-plumbs the graph like boot). Every re-open is logged
with the attempt count, so Jonathan's next log shows either "re-open recovered
audio" (fixed) or three silent re-opens (deeper Windows routing issue, and we know
exactly where we stand).

Decision core (ShouldReopenSilentCapture) is pure and pinned by a new self-test.
Gate: 39/39.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:08:21 +01:00
EdnunpandClaude Opus 4.8 1003bbfdf2 Per-app send fixes + apps-mode UI rework (no send-all) + truly-global remembered lists
Per-app capture (the "foobar alone = no sound" report):
- ProcessLoopbackCapture: take ActivateAudioInterfaceAsync's out operation as a raw
  IntPtr (released after completion) instead of a typed interface. The eager RCW cast
  of the not-yet-realised operation object threw InvalidCastException / E_NOINTERFACE
  and killed EVERY specific-app capture at start.
- CompositeCaptureBackend: single IsPushEligible authority shared by Start,
  UpdateSources and the coalesced rebuild. The update paths were missing the
  ProcessLoopback exclusion, so switching whole-device -> one app kept the push
  backend and fed it "proc:<pid>" (GetDevice ArgumentException).
- PushModeWasapiBackend: loud backstop rejecting process-loopback specs.
- Self-test: lifecycle test now FAILS on an activation error (it previously passed
  green with the feature completely dead) + pure routing-rule checks.

Apps-mode UI (Ed 2026-07-16): the "Send all applications" checkbox is GONE from the
main window - applications mode always means picking specific apps; whole-system
audio is devices mode's job. Active list = running apps + any ticked app that is not
running ("(not running)" so it can be unticked); Remembered list = global address
book minus whatever is ticked. In-place list reconcile (no Clear+rebuild) kills the
NVDA double-read of the toggled row. Profile.SendAllApplications stays for the
SERVICE (deliberate divergence - a headless lock-screen sender wants system audio).

Remembered lists now genuinely machine-wide (AppConfig-backed): the settings store is
an intra-process cache, so remembered applications were forgotten on every exit and
remembered peers were per-profile in practice. Both books moved to AppConfig; legacy
per-profile peers are unioned in on profile load; profile save snapshots the global
book back for old-build compat. Cross-instance persistence pinned by self-test.

Service (issue #23): 15s capture pulse (callbacks/bytes/pre-encode peak/frames sent)
while sending - distinguishes "endpoint mix is genuinely silent at the lock screen"
from a pipeline fault, which callbacks alone cannot.

Gate: 38/38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:50:38 +01:00
EdnunpandClaude Opus 4.8 92ad77ff52 Service: wire sender diagnostics into the service log + capture-health watch (issue #23)
Issue #23's log proved the service can be perfectly healthy on the network
(discoverable, heartbeats, peers armed) while its WASAPI loopback capture is
open but STARVED — zero buffers ever delivered on the boot lock screen, so
peers connect and hear nothing. And the log was blind below 'streaming N
sources' because the service never wired the audio engine's diagnostics in.

- sender.Diagnostic now feeds the service log: capture opens/failures, backend
  switches, the silence keepalive result, composite mode. The keepalive already
  runs on every loopback device (SilentRenderKeepAlive via MixingEngine); on the
  next boot repro the log will show whether it started or was refused in
  session 0 pre-login — the deciding fact.
- New capture-health watch on the existing 1s tick: logs 'first capture
  callback received' once audio genuinely flows, and an explicit 'ZERO audio
  callbacks after 10s' line naming the starved-loopback fault instead of the
  log just going quiet.

Gate 37/37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:56:39 +01:00
EdnunpandClaude Opus 4.8 18b079ba3a Send-app UI: 'Send all applications' is now the first row of the list
Per Ed: instead of a separate 'Send all applications' checkbox that hides the
list, that toggle is now the FIRST ROW of the active-applications list. Ticked =
send the whole system and the individual apps collapse away; untick it and the
running apps appear beneath it (the remembered list shows too). Backed by the
now-hidden sendAllApplicationsCheckbox as state; a reserved sentinel row name
routes the first-row toggle to it. One scrollable control instead of a checkbox
plus a list. Coverage audit + profile round-trip still clean; gate 37/37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:00:17 +01:00