100 Commits
Author SHA1 Message Date
EdnunpandClaude Opus 5 2f702e9880 Latency lab: add the ASIO-lane scenario (--latency-lab asio)
Answers Ed's "and adjusting ASIO latency also happens quickly now?" with a measurement rather
than an assertion, and without needing an ASIO device: wires the engine as BothIndependent with
only the ASIO lane active, confirms the stream lands on AsioLane, and drives the raise/lower
through the ASIO slider's own route.

Measured: 54ms -> 289ms within 10s, holds at the 330ms target, lower back to 30ms within 3s —
the same fast approach as the single slider, as expected (one hard-setter path feeding whichever
lane the moved slider owns). No product change; harness only. The shipped v5.9 build is untouched.

Also made the harness faithful to the 5.9 arming change (NoteFramesQueued against the session's
own route target, not the engine-wide one) so the ASIO case arms the way the app does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 07:25:27 +01:00
EdnunpandClaude Opus 5 1ee510bf58 v5.9: the latency control governs the streams it's supposed to, and arrives in seconds
Confirmed working on Ed's two machines before shipping (his log: slider 500 with 17ms buffered
at 07:10:31 -> 214ms at +3s -> 445 at +9s -> settled ~490 by +15s; auto-tune then walked it down
in 5ms steps with the buffer tracking; a drop to 20ms took effect immediately). Both directions,
auto-tune on and off.

Third fix in this batch, found while answering "is ASIO testing essential before we ship": the
ASIO slider only exists in BothIndependent, but MainForm pushes its persisted value at startup in
EVERY mode (and its auto-tune can tick). Now that all routes resolve to the shared value in
single-slider mode, that write would silently overwrite the visible slider with a hidden control's
number. SetMaxLatencyMs now ignores AsioLane writes when !independentLanes — in single-slider mode
the ASIO box governs nothing. Gate covers it, plus the ASIO half of the wiring (a stream on the
ASIO lane reads the ASIO slider; moving one lane doesn't disturb the other) — provable without an
ASIO device, since the change is about WHICH value a lane reads, not the driver path (untouched).

Docs: manual gains an honest paragraph on changing latency mid-listen (immediate down, a few
seconds up, the slight stretch is the change happening, no gap or click); About + RELEASE_NOTES
for 5.9; MANUAL regenerated. Version 5.9. Gate 74/74 + relay 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 07:15:31 +01:00
EdnunpandClaude Opus 5 dfdc243f03 Latency slider: one slider now means one value, and a move arrives in seconds
Two bugs behind the 2026-08-14 field report ("the latency slider does nothing" — a WASAPI-only
machine, auto-tune off, a clap test showing no change either way, while Ed's own two-slider rig
audibly works). Both found by MEASURING, not reasoning: --latency-lab drives the real playout
with realtime-paced sender/device shapes and records actual buffered depth once a second.

1. The slider wrote to a value nothing read. ReconcileReplicasLocked tags every session with an
   OUTPUT LANE (WasapiLane/AsioLane — never Mixed while a device is ticked), and the render path
   resolved its target from that lane. But MainForm.MaxLatencyBoxRoute sends the single slider to
   RenderRoute.Mixed in EVERY non-BothIndependent mode. So in classic modes the slider — and the
   auto-tune with it — updated a value the audio never consumed, leaving the real target on
   LaneLatency's 30 ms default for the whole session; raise AND lower equally inert (lower also
   matched on route, so it skipped every playing session). Only BothIndependent worked, because
   there the slider writes WasapiLane, which IS what its sessions read. That's precisely "works on
   mine, dead on his". Fix: the lane tag says which DEVICE a session renders through, not which
   knob governs it. One slider now means ONE value (sharedLatency) read by every session whatever
   its lane; two sliders only in BothIndependent, seeded from the shared value on entry so audio
   doesn't jump. Arming also uses the session's own route target (matters in BothIndependent).
   Measured, classic path: -0.15ms/s (STALLED) -> +2.07ms/s (GROWS).

2. Even correctly wired, a raise crawled. Steady-state depth feedback is capped at 0.3% (~3ms of
   catch-up per second) AND only recomputed at the 10s drift-window boundary, so a 400 ms raise
   took over two minutes and still read as a dead slider (Ed: "it needs to change the value
   quickly not over minutes"). A raise can't be met by dropping audio, only by banking it, so a
   DELIBERATE move (the hard setter — never the auto-tune's soft one, leaving its parked descent
   behaviour untouched) now engages a fast approach: 5% bias, recomputed 5x/sec, self-clearing
   within 15 ms of target. Audible glide, no gap, no click — the user asked for the change and
   hears it happen. Measured: 49ms -> 324ms within 10s, then holds.

Gate: new step pins the invariant fast and deterministically — what the slider sets IS what the
session's route reads, a lower reaches the session, and two-slider mode keeps its lanes separate.
74/74 + relay. --latency-lab (six scenarios) / --latency-lab classic (the faithful app path) stay
in the tree as the measuring harness.

NOT released. Test build for the reporting user: D:\Dropbox\remsound-latency-slider-test.zip

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:01:19 +01:00
EdnunpandClaude Fable 5 72ebfb50a8 About box shows only the newest 5 releases (screen-reader crash fix)
The About box's release notes had accumulated the ENTIRE version history back to v1.0 —
~70 KB in a single TextBox — and reading a control value that size crashes some screen
readers (user reports, 2026-08-11). The box now displays only the newest five version
blocks via a pure trim (TrimToLastVersions), ending with a plain pointer to the full
history on the GitHub releases page. The full constant stays in source as the archive.

Gate: new step pins the trim logic on synthetic notes AND the real shipped text (exactly
5 versions, size well under the crashing range — 5,516 chars vs ~70,000) so the crash
can't quietly return as releases accumulate. 73/73. Readme About section updated + MANUAL
regenerated; 5.8 notes mention the change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:35:13 +01:00
EdnunpandClaude Fable 5 e0dc2fec94 v5.8: service-folder repair — fix the 5.6 file-wedging ACL bug + record the real user through elevation
Root cause found while REPRODUCING the suspected wrong-owner bug in a gate test (the repro
failed in a way the theory couldn't explain, and the empirical icacls run showed why): the
5.6 hardening's /T sweep applied (OI)(CI) grants to FILES, where such ACEs are inherit-only
— they grant the file itself NOTHING. Every file existing at harden time was left with
/inheritance:r + inherit-only ACEs = an effectively EMPTY ACL: unreadable/unwritable by the
user, admins, even SYSTEM. That is the Jonathan report (2026-08-06) end to end: profile save
"access denied" even elevated, logs unreadable in Notepad, his own icacls /T "fix" adding
useless inherit-only ACEs (137 processed, nothing healed), new files fine (echo test), and
the service failing to start (SYSTEM can't read a wedged profile).

The fix, in layers:
- BuildServiceDirAclArgs no longer sweeps /T: the lockdown applies to the FOLDER only.
  Existing children are rebuilt by a new /reset pass (BuildResetChildrenArgs) as purely-
  inherited from the hardened folder ACL — real file access again, stale/planted explicit
  ACEs removed, and it HEALS files wedged by 5.6. Regression-pinned in the gate (the folder
  args must never contain /T again).
- Second bug fixed in the same area: the elevated helper recorded ITS OWN token as the
  "installing user" — under over-the-shoulder elevation that's the separate admin account
  whose password was typed, not the person at the keyboard. Elevated verbs now carry
  --as-user <SID> from the non-elevated app (validated: real user SIDs only — service
  identities and builtin groups rejected) and install/repair re-record it, so a stale wrong
  owner can't persist through reinstalls or self-update re-hardens.
- Self-heal everywhere: new --repair-service-access verb (re-record owner + re-harden);
  "Repair service folder access" in the Service menu; a startup write-probe that offers the
  repair when the folder is broken (settled startup sequence, ForegroundDialog, skipped on
  --silent); the profile-save UnauthorizedAccessException catch offers it at the exact wall
  users hit; and the service self-update's existing re-harden now runs the FIXED sequence,
  so wedged fleet machines heal automatically when 5.8 rolls out — no user action needed.
- Logs readable again: Users get read-only on service\logs (inheritable, no /T needed —
  propagation covers existing files) and on service-events.log. The profile stays locked
  (it holds the obfuscated password). GrantUsersWriteToBin deleted — the folder ACL's
  inherited user-Modify covers bin, and the reset wiped its explicit grants anyway.
- DoStart/DoStop no longer swallow the reason: exception recorded to service events, and
  distinct exit codes (6 timeout, 7 SCM refused, 9 repair-didn't-stick) let the dialog say
  what happened instead of the bare "(code 1)" that cost this diagnosis a day.

Gate: new "Service folder repair" step reproduces BOTH bugs for real in a scratch folder —
wedges a file with the exact 5.6 spec (proves reads genuinely die), locks the folder to the
wrong owner, asserts the app's probe reports broken, runs the exact shipped repair sequence
(ApplyServiceDirAcl), and asserts folder writable + wedged file readable again. Plus a
SID pass-through step (validation, parse, arg-building, logs-grant shape). 72/72 + relay 7.

Docs: Service-menu repair item + troubleshooting entry in readme; About + RELEASE_NOTES
rewritten for 5.8; MANUAL regenerated. Version 5.8. NOT released — awaiting Ed's test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:24:44 +01:00
EdnunpandClaude Fable 5 2cf4948bfb v5.7: back out the enforced stronger passwords (restore cross-port compatibility)
The v5.6 password change went down badly and broke the other ports: raising PBKDF2 to
600k changed the key recipe, so the SAME password produced a DIFFERENT key on 5.6 vs
every other build (the iOS/TestFlight app, older desktops) — same password, no audio.
Ed: back it out so the other ports can use it again; a password is still required, just
suggest a strong one.

Reverted:
- PBKDF2 100k again (RemSoundCrypto) — the key recipe now matches the other ports, so
  the same password derives the same key and audio flows again. Pinned by a gate check
  so an accidental change can't silently re-break cross-port compat.
- ForPlainPassword no longer refuses a weak password: ANY non-empty password derives a
  key (encryption stays mandatory — empty still means no audio). Strength is not enforced.
- Removed every enforcement/nag: the streaming-tick force-strengthen, the startup
  weak-password dialog, the status-line weak warning, the app's weak-SERVICE-password
  launch + live nags, and the service-profile save-time block. The password dialogs just
  suggest a strong password in their hint now; PasswordStrength.cs deleted (now unused).

Someone who set a strong password because of 5.6 feels no difference on update — their
password still loads and works, no prompt (Ed's requirement).

Kept (separate from "stronger passwords", not backed out): signed updates, password-
sealed remote volume, service startup volume, the update time-window, the nonce widening,
and the relay address-proof. NOTE: sealed remote-volume still needs both ends on 5.6+ —
ordinary audio does not. Flagging in case full remote-volume interop with old ports is
wanted too.

Docs: About + readme + release notes rewritten for v5.7 (plain English, positive framing,
no "must update"). MANUAL.md regenerated. Version 5.7. Gate 70/70 (dropped the now-moot
streaming-strengthening step) + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 14:01:12 +01:00
EdnunpandClaude Fable 5 cce3db8120 Relay: bump VERSION to server-v2.5 (address-proof + per-IP cap, watch-only)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:59:55 +01:00
EdnunpandClaude Fable 5 2791f855a7 Release prep v5.6: regen MANUAL.md from readme.html; gate skips Store-alias python
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:55:48 +01:00
EdnunpandClaude Fable 5 d09c47432f Dialog-focus sweep: route every background-reachable dialog through ForegroundDialog
Ed flagged this as recurring. Swept all ShowDialog call sites and fixed the ones that
can appear while RemSound is minimised in the tray (a plain ShowDialog opens BEHIND
everything there — no foreground focus, NVDA can't find it):

- Quick profile switch (QuickProfileSwitchDialog): opened by a GLOBAL hotkey, so the
  app is usually minimised when it fires. Was using an ad-hoc SetForegroundWindow that
  Windows blocks from a background process (only flashes the taskbar). Now via
  ForegroundDialog; deleted the weak BringToForeground + its P/Invoke.
- Close-time unsaved-changes prompt (OnFormClosing): fires from tray -> Exit and OS
  shutdown while minimised. Now via ForegroundDialog. (Also covers the tray profile-
  switch, which routes through the same form-close path.)
- (Service-profile dialog was fixed in the previous commit.)

Verified the rest are safe: startup notices, update notices, the service/mic/Realtek/
About warnings and the password dialogs already use ForegroundDialog; every other
ShowDialog(this) is menu- or button-driven with the app already focused (Preferences,
EQ band, rename, recording settings, profile picker, hotkey capture nested in the
menu-driven shortcuts dialog). Recorded the rule in memory so it stops recurring.

Gate 71/71 + 7 relay tests; 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:40:34 +01:00
EdnunpandClaude Fable 5 6a1b4075b9 Fix: service-profile dialog must open with foreground focus (NVDA)
Ed clicked Yes on the weak-service-password nag and the service-settings dialog opened
BEHIND everything — he had to Alt-Tab to find it, and the nested change-password
dialog with it. Cause: ConfigureServiceProfile opened ServiceProfileDialog with a
plain ShowDialog(this), which does NOT surface the dialog when the main window is
minimised in the tray. The app already has the fix — ForegroundDialog (the
1x1-owner + foreground-lock dance the mic/Realtek/About warnings use) — but this
dialog bypassed it, and it's now reachable from a nag that fires while minimised.

Now routed through ForegroundDialog, so it opens front-and-centre with focus wherever
RemSound is sitting. The nested "Set service profile password" dialog already uses
ForegroundDialog, so it inherits the foreground once its parent is up (that was Ed's
second complaint — a cascade from the unfocused parent, not a separate bug).

Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:26:28 +01:00
EdnunpandClaude Fable 5 bd31eda972 Live nag for a weak service password while the app is already open
Follow-on: the weak-service-password warning fired only at app launch, so a service
that went weak underneath a running app (or that the user hasn't relaunched the app
since) wouldn't be surfaced until the next launch. Now the 1 Hz status tick also polls
it — self-throttled to at most every 15s and one-shot per app session (the flag is set
before the modal so a re-entrant tick can't stack it), sharing the same
ServicePasswordNeedsStrengthening decision and the same offer-to-open-service-settings
dialog. Costs nothing once handled or once the password is strong.

(Confirmed against the service's DIAGNOSTIC log — the strength-rule flag has been
present since the 08:20 self-update; an earlier check looked in the wrong log file.)

Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:17:01 +01:00
EdnunpandClaude Fable 5 e82268f925 Weak SERVICE password: warn from the app, since the headless service can't ask
Ed's point: the send-only service auto-updates itself to 5.6, and a pre-existing weak
service-profile password then leaves it silently not streaming — the service is
headless (SYSTEM, no window) so it can't prompt, and nobody watches its log. Users
would never know. Closed from the two places a user WILL see:

1. App launch (MaybeWarnWeakServicePassword, in the settled RunStartupNotices
   sequence): if a service is installed and its profile password is set-but-weak, a
   readable, focus-clean dialog warns and offers to open the service settings to fix
   it. Self-resolving — stops once the service password is strengthened. The person
   running the service almost always has the app (that's how it's configured), and
   the app updates around the same time the service does, so this fires right when it
   matters. Decision extracted to the pure ServicePasswordNeedsStrengthening (tested:
   installed+weak warns; not-installed / unset / strong don't).

2. Saving the service profile (ServiceProfileDialog): Save now validates first — a
   weak password pops a warning offering to set a stronger one inline (requireStrong),
   Save-anyway, or Cancel. The save button no longer auto-closes; it validates then
   closes explicitly.

readme: a sentence in the service section explaining the service password follows the
same rule and how RemSound surfaces a weak one. Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:03:49 +01:00
EdnunpandClaude Fable 5 9577fc9940 Shorten the weak-password messaging everywhere (Ed's approved wording)
The password guidance was a wall of text. Trimmed to Ed's approved phrasing, applied
consistently across all three surfaces (status line left as-is per Ed):

- PasswordStrength.Critique is now ONE line — the rule + a concrete example to copy
  ("Use at least 8 characters — three unrelated words with a number, like
  kettle9tiger42moon, works well."), no scare prose. This flows into both change-
  password-box rejection dialogs, so those shortened automatically.
- Startup warning (MaybeWarnWeakPassword): "RemSound has increased its security level,
  so this profile's password must be strengthened to meet the new password rules.
  Until it is, no audio will pass." + the one-line rule + how to change it.
- Send/Receive-tick gate (EnsureStreamingPassword): same "increased its security
  level ... strengthened before audio can flow" opener + the concise rule.

Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 11:23:27 +01:00
EdnunpandClaude Fable 5 af06929a64 Weak-password warning: a real focus-getting dialog, from the settled startup sequence
Ed's follow-up: a status line is too easy to miss for something this important — a
dialog that reliably gets focus is wanted. The earlier dialog wasn't wrong in kind,
only in timing: it fired mid-connect from the crypto path, before the window had
finished showing, so it never got clean foreground focus and NVDA couldn't reach it
(and ForegroundDialog dragged the half-shown window out of the tray).

Fix: show the warning from RunStartupNotices — the SAME proven, post-OnShown,
one-dialog-at-a-time sequence the mic-blocked and Realtek warnings use, which reach
NVDA fine. New MaybeWarnWeakPassword uses ForegroundDialog.Show (fronts even from the
tray, clean focus) and fires only when the profile is actually set to stream (send or
receive on) and its password is genuinely weak. The non-modal status-line reminder
stays as the standing prompt after the dialog is dismissed, and the user-initiated
streaming-tick gate is unchanged. Skipped on --silent/muted launches like its peers.

Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 11:10:24 +01:00
EdnunpandClaude Fable 5 027ba905e7 Fix (NVDA-critical): weak-password state must never pop a modal at startup
Ed launched 5.6 with a profile carrying the old weak "Games" password and hit a
show-stopper: a dialog he could neither read nor dismiss, and the window came up on
the connectivity screen instead of minimised. Cause: ExplainWeakPasswordIfNeeded fired
a modal TaskDialog automatically from RecomputeAudioCrypto on profile load / auto-
connect, via ForegroundDialog.Show — which also drags the window to the foreground
(hence the ignored "minimised"). A modal that steals focus mid-startup, before NVDA
can reach it, locks a screen-reader user out completely.

Fix: no automatic modal on the weak-password path. The state is surfaced NON-modally
and persistently in the status line (UpdateStatus, via the new pure WeakPasswordBlocks-
Audio) — NVDA reads it at the user's own pace, and it leads the line so it's spoken
first. The guided modal prompt is kept ONLY for the user-INITIATED streaming tick
(EnsureStreamingPassword), where the user just pressed a key so focus is clean and the
dialog is reachable; that path is already suppressed during startup profile-apply, so
there is now no startup modal at all. WeakPasswordBlocksAudio correctly stays false for
a strong password still deriving off-thread (key null, Critique null), so that transient
never shows the warning.

Test: WeakPasswordBlocksAudio pinned (weak+no-key true; empty/strong-deriving/has-key
false). Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 11:02:50 +01:00
EdnunpandClaude Fable 5 93bf1d86eb Fix: service startup-volume re-punching on every restart + misleading suspend log
Ed reported the service pulling his volume to 20% repeatedly. Diagnosis from the
service logs: NOT a runaway timer - one service process ran untouched for 36 hours,
proving there's no periodic restart. The volume-to-20 punches came from GENUINE
service restarts (deploys, the one self-update, profile saves, handovers during
active use), each re-applying because the mode was "every service restart". Two real
faults found and fixed:

1. Re-apply burst guard. "Every service restart" (and boot-only as belt-and-braces)
   now skips a re-apply within ReapplyCooldown (5 min) of the last successful apply,
   persisted as startup-volume-last.txt. This kills the double-apply we saw in the
   log at 08:20:48 then 08:21:02 (a self-update restart immediately followed by a
   follow-on start, 14s apart, both punching the volume), and any rapid churn from
   self-update / profile-save / deploy. ShouldApply gains lastApplied+now params;
   future-dated stamp (clock moved back) can't wedge it.

2. Misleading log. ServiceSendHost.Suspend() hard-coded "interactive app present" on
   EVERY suspend, including plain shutdown - which sent this very investigation
   chasing phantom app-handovers. Suspend now takes a reason; the run-loop-ending
   path says so, only a real app yield says "interactive app present".

readme: recommend "first start after boot" as the set-and-forget mode and explain
that "every restart" also fires on routine internal restarts (and is now burst-
guarded). Self-test extended: cooldown skip in both modes, 14s double-apply guard,
future-stamp safety. Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:36:54 +01:00
EdnunpandClaude Fable 5 65e999466d Test gaps 4-6: relay unit tests, updater-refusal, password walk-through
Closing the coverage gaps the review flagged as blind spots we'd be relying on at
release:

4. RELAY LOGIC TESTS. server/test_relay.py (stdlib unittest + a FakeSocket, no network)
   covers the address-proof end to end: cookie issued on join, wrong cookie rejected,
   right cookie verifies once; enforce mode WITHHOLDS forwarding from an unverified
   address then delivers after it proves itself; watch-only forwards but records
   would-block; the per-IP cap counts across BOTH v1 and v2; a NAT-rebind clears
   verification (spoof-takeover guard); a forged BYE from another address can't evict
   the victim; and bad/short/unknown-version headers are refused. Wired into
   run-tests.ps1 (Start-Process from server\, SKIPs loudly if no Python) so a relay
   change can no longer ship past the gate untested. The relay had ZERO automated
   coverage before and auto-updates every user.

5. UPDATER SIGNATURE ENFORCEMENT. Extracted the two refusal branches into a pure
   VerifyStagedRelease gate and added UpdaterRefusesUnsignedRelease: no-sig refused,
   wrong-key refused, garbage refused, tamper (good sig over changed bytes) refused,
   genuine release accepted. ReleaseSigning only proved the crypto; this proves the
   updater actually REFUSES - the hijacked-release-stream threat.

6. STREAMING PASSWORD STRENGTHENING. The accept decision is now a pure
   ProfilePasswordDialog.RejectionAdviceFor shared by BOTH password dialogs (also
   fixes the App-review trim inconsistency - manager dialog compared untrimmed). Test
   pins the load-bearing rule: requireStrong DISABLES the unchanged-exemption so an
   existing weak "Games" can't keep streaming, while casual mode still grandfathers an
   unchanged password and blocks a new weak one, trim-safe.

Plus the NVDA-hang cache assertions in PasswordRules (miss->hit, same-instance repeat,
Prewarm, empty/weak = no work).

Gate 71/71 + 7 relay tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:11:52 +01:00
EdnunpandClaude Fable 5 1b6402f786 NVDA hang fix: background the 600k PBKDF2 + Core session key cache
The 5.6 password-strength raise made key derivation 6x slower (~1s on old hardware,
run twice for key+fingerprint), and RecomputeAudioCrypto ran it synchronously on the
UI thread on profile load / first connect - a full NVDA freeze for a screen-reader
user. Fixed two ways:

1. Core session cache (RemSoundCrypto): ForPlainPassword now caches (key,fingerprint)
   per plaintext password for the process, with Prewarm (derive off-thread without
   returning) and IsCached (is it a hit?). The slow PBKDF2 runs ONCE per distinct
   password per process, so a profile switch back to a used password is instant. This
   also fixes the efficiency-review finding that the SERVICE re-derived un-cached on
   every audio-device hot-plug - it now hits the same cache.

2. App off-thread derive (MainForm.RecomputeAudioCrypto): the fast cases (password
   unchanged / already cached / empty / weak - no PBKDF2) still apply synchronously;
   the ONLY slow case (a strong password's first use this session, with the window
   live) derives on a worker and applies on the UI thread when done. The key is held
   null until it lands - audio simply waits (mandatory encryption never streams
   keyless) rather than the UI freezing - and a generation counter discards a stale
   result if the password changed again meanwhile. Falls back to synchronous before
   the window handle exists (startup construction).

Also: the weak-password explanation is factored into ExplainWeakPasswordIfNeeded and
its one-shot flag now re-arms on a real password change, so a SECOND weak-password
profile switched to in one session is still explained (review nit #6).

Tests: PasswordRules now pins the cache (miss->hit, same-instance on repeat, Prewarm
warms, empty/weak = no work). Gate 69/69.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:02:31 +01:00
EdnunpandClaude Fable 5 26a6fbfdf6 Pre-release hardening 1-3: nonce prefix widen + status-text dedup + stale comments
From the four-agent pre-release review (all read-only), the three items that warranted
fixing before ship:

1. NONCE PREFIX WIDENED (the one security-relevant finding). The interim counter-nonce
   used a 32-bit random prefix + 64-bit counter; since a fresh instance restarts the
   counter at 0, two instances that drew the same prefix would reuse nonces under the
   same long-lived key (catastrophic for AES-GCM), and 32 bits collides at only ~2^16
   instances. Now 48-bit random prefix + 48-bit counter: birthday bound ~2^24 instances
   while 2^48 packets/session stays far beyond any real session - strictly safer than
   both the interim scheme AND the original per-packet 96-bit-random nonce. Wire
   unchanged (receiver reads the nonce off the packet). Comment corrected.

2. STATUS-TEXT DE-DUPLICATED. The CheckedListBox spoken-status builder existed in two
   copies (CheckedListAccessibility + MainForm) with a comment falsely claiming they were
   "exact" - and they had already drifted (the remembered-apps empty-state line was in the
   MainForm copy only). Folded to ONE builder (CheckedListAccessibility.ApplyStatus +
   EmptyTextFor); MainForm delegates. NVDA wording is now identical in the main window and
   every dialog by construction. No user-visible change today; removes the silent-desync trap.

3. STALE COMMENTS. RemPacket still documented the pre-5.6 plaintext control wire format
   (2 bytes / 14 on the wire); it is now sealed (50 on the wire, the 2 bytes are inner
   plaintext). Fixed that and the MainForm.Peers.cs comment that misplaced CaptureSpecBuilder
   in Core (it is App by necessity). Docs only.

Plus review-flagged test top-ups: NonceSequence layout + two-instance-distinct-prefix
assert; ControlReceiveGuard future-dated rejection + 300-command burst (no false replay
across the prune threshold); log-rotation INTEGRITY (every line survives exactly once)
+ a 4-thread concurrency test that actually fails if writeGate is removed; ReleaseSigning
now honestly SKIPs (not caveat-PASSes) when the publisher key is absent.

Gate 69/69.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:57:25 +01:00
EdnunpandClaude Fable 5 6c53fe54d1 v5.6 batch: signed releases + stronger passwords enforced + relay address-proof
The everyone-must-update release. Four coordinated changes, each from the security
discussion Ed approved 2026-07-27, plus the remembered-apps polish:

1. SIGNED RELEASES. build-release.ps1 now signs the release zip (ECDSA P-256 /
   SHA-256, --sign-update verb) with a private key that lives ONLY at Ed's chosen
   location outside the repo; the matching public key is embedded (UpdateSignature)
   and the updater REFUSES any release whose .sig asset is missing or does not
   verify - a compromised GitHub account can no longer ship code to users. The
   signing verb self-checks against the embedded key so a key/embed mismatch fails
   the pipeline, and the gate proves the on-disk key matches the embed when present.

2. STRONGER PASSWORDS, ENFORCED (BREAKING). PBKDF2 raised 100k -> 600k (both peers
   must derive the same key, so 5.6 cannot stream with pre-5.6 AT ALL - release
   notes lead with it). New PasswordStrength rule (>= 8 chars, not an infamous
   password) enforced at EVERY door: both password dialogs block weak NEW entries
   with concrete plain-English advice; the streaming gate walks an existing weak
   password through strengthening; and ForPlainPassword - the single derivation
   choke-point shared with the service - refuses weak outright, so no path streams
   on a guessable password. Headless service logs the why. Per Ed: painful once,
   and this coordinated-update release is the cheapest moment it will ever have.

3. RELAY ADDRESS-PROOF (watch-only). The relay sends every new client address a
   random cookie and marks it verified when echoed - a forged source address can
   never echo, killing the reflection attack. 5.6 clients echo automatically
   (AddrCheck type 10, verbatim, self-limiting); the relay ships watch-only
   (logs would-blocks) until the fleet updates, then one flag (--require-addr-check)
   enforces. Per-IP entry cap (4) enforced immediately. Relay changes are committed
   but NOT deployed to the Pi - they ride the v5.6 release moment.

4. Remembered-apps empty state teaches its lifecycle + manual sentence; About/
   release notes written; version bumped to 5.6.

New gate steps: signing round-trip/tamper/wrong-key/embed-match; password rules incl.
the exact "Games" case; AddrCheck verbatim echo. Gate 69/69.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 08:19:20 +01:00
EdnunpandClaude Fable 5 9574d9d08b Update install window: automatic updates only within a chosen daily time range
Feature (requested 2026-07-26): Preferences' update settings gain 'Only install
updates within this time range' - a checkbox plus Start/End time lists in 15-minute
steps (00:00-23:45, default 01:00-06:00). RemSound streams live audio, so an
automatic update mid-session kills someone's sound; with the range on, the startup
check and the background poll DEFER an available update until the range opens (a
one-shot timer retries right at the range start, so a 24-hourly poll can't keep
missing the window for days). Manual 'Check for updates now' is deliberately never
gated - asking by hand means now. End at-or-before start wraps past midnight
(22:00-06:00); start minute inclusive, end exclusive; an equal start/end means
unrestricted rather than a silent never-install trap. The service inherits the
window for free: its self-update follows the app's install.

readme: the two new Preferences rows, the service startup-volume option, and a
security note on password-locked remote volume (both ends need 5.6).

New gate step pins the window maths (same-day, wraparound, boundaries, empty-range
rule, retry arithmetic). Dialog audit covers the new controls. Gate 66/66.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:52:21 +01:00
EdnunpandClaude Fable 5 bb94a56109 Service startup volume: unmute + set level on boot or on every service start
Feature (requested 2026-07-26): Additional service options gains 'Set the machine's
volume when the service starts' - a checkbox, a percent field (0-100, also unmutes),
and a WHEN list: 'Only the first start after each boot' (default) or 'Every time the
service starts'. For an unattended machine that boots muted or turned down, the
service makes it audible again with nobody at the keyboard; boot-only mode means a
mid-day manual service restart never blasts the volume while someone's using the box.

Mechanics: settings live machine-wide beside the service logging flag (the service
reads them fresh each start - no restart needed to change them); boot identity comes
from now-minus-uptime persisted in a marker file, so 'first start after boot'
survives same-boot service restarts and re-fires after a real reboot; the marker is
only written on a SUCCESSFUL apply, so a boot-time audio-stack race retries on the
next qualifying start. Applies via the same endpoint-volume helper the remote-control
commands use (master endpoint volume is device-global, so session 0 works). Outcome
logged to the always-on service events log.

The new round-trip test immediately caught SaveLoggingEnabled clobbering the volume
fields in the shared settings file - rewired both savers to load-modify-save. The
dialog audit picks up the new controls (mnemonics + names) automatically.
Gate 65/65.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 23:45:55 +01:00
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
EdnunpandClaude Opus 4.8 3103bac791 ASIO: park the driver on switch-away instead of closing it (Ed's fix)
The 'set ASIO to none => app dies' crash is the native close inside the Audient
driver. Per Ed: don't tear it down, just stop using it. EnsurePersistentAsioLocked
now, when switching to WasapiOnly/none, keeps the persistent ASIO instance OPEN
but PARKED — rewires its callback to a no-op and drops it to zero active pairs
(UpdateSources([]), which explicitly never closes the driver). No native close =
no crash. It's reused instantly if ASIO is turned back on (also dodges the
close+reopen hang), and only truly closes on a driver change or app exit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:55:33 +01:00
EdnunpandClaude Opus 4.8 0c67897448 ASIO close: step-by-step logging + drain before release (Audient crash)
The 'set ASIO driver to none => app dies completely' crash is a NATIVE crash
inside the driver's own Stop/Dispose (Audient), which bypasses every managed
try/catch and leaves no crash file. Two changes to pin it down and reduce it:
- Log each close step (unhook / stop / release / released) so the next crash's
  log ends right after the native call that died, naming it exactly.
- Sleep 60ms after unhooking the callback, before Stop/Dispose, so the native
  close isn't racing an in-flight ASIO buffer callback (a common crash trigger).

Diagnostic + mitigation; the underlying native driver crash needs the Audient
hardware to confirm. Gate 37/37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:50:21 +01:00
EdnunpandClaude Opus 4.8 d9c78a5956 Two app lists in the main window: Currently active + Remembered
In WASAPI 'applications' send mode you now see two checkbox lists, mirroring the
peers lists:
- Currently active applications (Alt+8) — apps running right now.
- Remembered applications (Alt+9) — the global remembered-apps address book,
  including apps that aren't running (shown '(not running)').

Both drive ONE shared send set (selectedSendApps): tick an app in either list
and it's marked to send; the other list re-renders to match. Tick a remembered
app that's closed and, the instant it goes live, it appears ticked in the active
list AND capture begins from its start (the session-start watcher). Unticking in
either removes it. selectedSendApps is the source of truth (= per-profile
SelectedSendApplications on save); both lists render their checkboxes from it.

Layout: inserted the remembered list at row 11, shifted the send-input rows down.
Reconcile now rebuilds both lists; capture change-detection and profile
round-trip unchanged. Coverage audit now 42 controls, mnemonics + tab order
clean. Gate 37/37. Live two-list sync + capture wants on-machine testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:29:11 +01:00
EdnunpandClaude Opus 4.8 e95fc36926 Remembered lists are global; add Clear buttons in Preferences
Remembered peers were already machine-wide for the main app; remembered apps are
now global too (a shared address book across all profiles), so it doesn't confuse
the user with a different list per profile.

- RemSoundSettingsStore.Load/SaveRememberedApplications (global, NOT written into
  profile files) mirrors remembered peers. Removed the per-profile
  Profile.RememberedSendApplications. Ticking an app in any profile adds it to the
  global list (RememberCheckedApps); the per-profile SelectedSendApplications
  (the active/ticked subset) is unchanged.
- Preferences -> General now has two buttons at the end, under a 'Remembered
  lists (shared across all profiles)' header: 'Clear remembered peers list...'
  and 'Clear remembered applications list...', each with a Yes/No confirmation.
  Clearing peers refreshes the main window; clearing apps empties the global list.
- Self-test 'Remembered applications list is global + clearable' (round-trip,
  case-insensitive dedupe, clear); the dialog accessibility audit covers the two
  new buttons (names + mnemonics, no clash). Gate 37/37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:54:55 +01:00
EdnunpandClaude Opus 4.8 1e5030b08f Catch a per-app send from its very start (instant capture-on-open)
Fixes the bug where a ticked app that launched AFTER the profile loaded was
never captured: the 3s reconcile only redrew the list and only ran while you
were looking at it, so it never restarted the audio capture.

Now:
- AudioSessionStartWatcher (RemSound.Sender): hooks the Windows audio-session-
  created notification on the default render device, firing the instant an app
  opens an audio session (i.e. about to make its first sound). Re-points on a
  default-device change. Best-effort; falls back to the poll.
- RefreshSendAppCapture: re-applies the send sources whenever the ticked apps'
  running process ids change, driven by both the session watcher (instant) and
  the reconcile poll (backstop) — and the poll now runs regardless of which tab
  is showing, so a remembered app is caught even when you're not on the list.
- Profile.RememberedSendApplications added (persisted; the two-list UI that
  uses it lands next).
- Self-test 'Send-app capture change-detection': the pure signature is stable
  while nothing changes and flips when a ticked app opens/closes. Gate 36/36.

Live behaviour (real apps + the session notification) needs on-machine testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:54:27 +01:00
EdnunpandClaude Opus 4.8 5129aa2a39 Always-on service events log + show failure reasons on screen
So that if the service breaks on a machine where nobody enabled logging first
(e.g. a Win7 tester), we still learn WHY:

- New always-on ServiceStore.AppendServiceEvent -> service-events.log in
  ProgramData, independent of any logging toggle (like the update log).
- Service menu status-query failure now shows the actual reason IN the status
  line (a screen reader reads it) AND records it to the events log, instead of a
  bare 'status unavailable' whose reason only went to the off-by-default app log.
- ServiceAction (install/uninstall/start/stop) records request + outcome to the
  events log.
- ServiceEntry (the elevated child) logs each verb's start, and CATCHES a
  System.ServiceProcess load failure to record its reason before rethrowing (so
  the crash file still gets the full stack) — this is the likely Win7 failure.
- 'View service log' falls back to the events log when no runtime diagnostic log
  exists, so there's always something to view after any service interaction.

Recap of coverage: hard crashes -> crash-*.txt (always, no setup). Graceful
service failures -> now the events log + on-screen reason (no setup). Gate 35/35.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:08:49 +01:00
EdnunpandClaude Opus 4.8 4d1815e071 Show the Service menu on all Windows (let Win7 try the service)
Removes the Windows-10+ version gate on the Service menu so a Win7 user can
actually attempt Install/Configure and we can see whether the service works
there. Still launch-safe: building the menu references no service type (inlined
const verbs + method-group handlers), so System.ServiceProcess is not loaded at
window construction on any OS — verified by the 'main window builds without
loading the service assembly' self-test and an empirical 0-modules launch check.
The assembly loads only when the menu is opened (status query) or an action runs,
both try/catch-wrapped, so on Win7 it degrades to 'status unavailable' rather
than crashing. Re-gate with IsWindowsVersionAtLeast(10,0) if Win7 can't run it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:59:11 +01:00
EdnunpandClaude Opus 4.8 5fa88aba5c Service: reachability-gated sending (issues #8/#15), matching the app
Checked the service's connection handling against the networking issues that
shaped the main app. The service reused the low-level protocol components
(discovery, heartbeat, listener, sender), so it inherits their behaviour — but
it was MISSING the app's higher-level connection management from
MainForm.RefreshAudioReceivers: it called SetReceivers once with ALL configured
peers and blind-sent forever, even into dead addresses. That's exactly issue #8
(streaming into a peer that's gone) and it ignored issue #15 (retry/recover).

Fix: the service now streams ONLY to peers the heartbeat can reach, drops any
that stay unreachable past a 30s grace window, and re-arms them the moment they
recover — the same logic (and 30s threshold) as the app. Runs on the service's
existing 1s poll tick (no new timer, no background pile-up). Send-only, so no
"actively receiving" carve-out.

- ServiceNetworkPresence.PeerHealthSnapshot() exposes the heartbeat health.
- ServiceSendHost.ComputeArmedEndpoints (pure) + RefreshSendArming, wired into
  RunLoopCore.
- Self-test "Service reachability-gated sending": reachable armed, long-
  unreachable dropped, grace-window kept, no-data arms all. Gate 35/35.

Coverage of the other networking issues: multi-homed LAN+VPN (#18) is a
receiver-side allow-list fix — N/A to a send-only service, and its sender-side
support (announcing on all interfaces) is inherited from PeerDiscoveryService.
Forced/locked IPs (#17/#7): the service resolves peers literally and never
follows names, so it's inherently "locked" (what #17 asked for). Device
recovery (#5): already built. NOT built: discovery-based name-following (the
app can chase a peer whose IP changes); the service stays on its configured
addresses by design — flagged for Ed to decide if the service needs it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:33:54 +01:00
EdnunpandClaude Opus 4.8 c482857314 Fix the actual Win7 launch crash (issue #22): empty send-mode list
The real cause — from the tester's crash stack, not my earlier (wrong)
System.ServiceProcess theory: ApplySendModeVisibility() is called early in the
MainForm constructor (via ApplyAsioMode), BEFORE the Input/Output tab populates
the send-mode ListBox. On Windows 7 process-loopback is unsupported, so it took
the `!supported` branch and set SelectedIndex on an EMPTY list, throwing
ArgumentOutOfRangeException ("value ('0') must be less than '0'") and crashing
at launch. On Windows 10/11 that branch is skipped (process-loopback IS
supported), which hid it from the gate and from every Win10/11 test.

.NET 10 genuinely runs on the tester's Win7 SP1 box (CoreLib 10.0.826 in the
crash) — so the earlier assembly-load theory was wrong; those Win7 changes were
addressing a non-problem. This is the fault.

Fix: guard the SelectedIndex set with `sendModeList.Items.Count > 0`. When the
list is empty there's nothing to reset (it's created selecting Devices, and this
runs again once the tab is built).

Test: "Main window builds where process-loopback is unsupported (issue #22)" —
forces ProcessLoopbackCapture.IsSupported = false (new ForceSupportedForTest
seam) and constructs the headless MainForm, so a Win10/11 box exercises the Win7
path. VERIFIED it reproduces: with the guard reverted the test fails with the
exact tester exception; with the guard it passes. Gate 34/34.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:18:30 +01:00
EdnunpandClaude Opus 4.8 0567ea22c3 Restore Win7 launch safety: version-gate the Service menu, don't probe
The capability PROBE (added earlier for feature-detect) constructed a
ServiceController during window construction, which loaded System.ServiceProcess
on EVERY launch. On Win11 that's harmless, but on Win7 it meant the app was
attempting that risky load at startup, surviving only on a catch — too fragile
for a guarantee. Verified empirically: a normal launch was loading
System.ServiceProcess.ServiceController.dll again.

Fix: decide Service-menu visibility with a Windows-VERSION check
(OperatingSystem.IsWindowsVersionAtLeast(10,0)) instead of a runtime probe. A
version check touches no service type, so the service assembly is NOT referenced
at launch on any OS. On Win7/8 the menu is hidden and that assembly is never
loaded; on Win10+ it loads only later, if the user actually opens the Service
menu (already wrapped in try/catch). Feature-detecting on Win7 and keeping Win7
launch-safe are mutually exclusive (you can't test the load without doing it),
so we choose guaranteed launch safety.

- Deleted ServiceCapability (the probe) — now unused.
- Replaced its self-test with "Main window builds without loading the service
  assembly (Win7-safe)": constructing the main window must not load
  System.ServiceProcess. Verified empirically too: normal launch now loads 0
  service modules (was 1).

Gate 33/33.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:03:17 +01:00
EdnunpandClaude Opus 4.8 541f66d379 Make the service a reachable network peer (discoverable + connectable)
The send-only service could never be found or connected to — it only pushed
audio blindly to fixed peer addresses, with no beacon and nothing listening.
So a phone could neither discover it nor dial it. This gives the service a real
network presence built from the SAME components the interactive app uses, wired
the same way, so its discovery / heartbeat / NAT-pinhole / relay behaviour is
identical to the app's — which is what makes it "one identity" (both announce
under the machine name and pair through the relay the same way). Works LAN and,
inheriting the app's relay path, across the internet.

New ServiceNetworkPresence (reuses PeerDiscoveryService + AudioReceiver listener
+ HeartbeatService, wired to the host's AudioSender):
- Discoverable: announces send-only under the machine name (LAN broadcast +
  unicast to the configured peers for across-the-internet).
- Reachable: binds the well-known audio-port listener; PLAYBACK stays OFF
  (send-only never plays received audio — the listener only carries
  heartbeat/pairing).
- Pairable: heartbeat pings the peers (opens the NAT pinhole, drives relay
  pairing); replies route back on the listener (LAN) or the sender socket
  (relay).

Integrated into ServiceSendHost: comes up alongside the sender while streaming,
and — critically — tears ALL the way down to a shell on Suspend (stop
announcing, unbind the port, stop the heartbeat) so the service and the
interactive app never both hold the network. A brief dropout on that handover
is accepted (Ed's call); only one owns the network at a time.

Tests: "Service network presence" (Start binds the listener + comes up; Stop
unbinds to a shell; re-startable). "Service send host" now also asserts the
presence comes up with streaming and drops to a shell on Suspend. Gate 33/33.

NOTE: the live discover/connect/relay path can only be proven by the tester's
phone — the headless tests prove the lifecycle and teardown, not the internet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:52:38 +01:00
EdnunpandClaude Opus 4.8 91f5fc74c0 Add "View service log" (diagnostic activity log), distinct from update log
Tester couldn't see anything under "View service update log" -- that item only
shows the SELF-UPDATE log (written when the service updates itself to a new
version), so with no update it's empty. What he actually wanted is the service's
ACTIVITY log, which records why the service is or isn't sending
("streaming N sources to M peers", "profile has no WASAPI send sources",
"no reachable peers"), but there was no way to open it.

- New Service menu item "View service log" opens the newest diagnostic log in
  ProgramData\RemSound\service\logs. If none exists it explains that service
  logging must be enabled first (Configure service profile -> Logging), so the
  path to getting a log is discoverable.
- ServiceStore.LogsDirectory + NewestLogFile() to locate it (same absolute path
  for the SYSTEM service and the interactive user).
- Clarified the update-log "nothing yet" message to point at the new item.

This also unblocks diagnosing the pre-login send issue: enable service logging,
reproduce, then View service log to see the exact reason.

New self-test "Service log discovery": folder resolves under the service dir,
newest .log is chosen, empty case handled. Gate 32/32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:18:18 +01:00
EdnunpandClaude Opus 4.8 e3e57affb4 Fix Service menu shortcut: Alt+J, not Alt+S (Send owns Alt+S)
Alt+S didn't open the Service menu because the always-visible "Send my audio"
checkbox already owns Alt+S, and a visible control beats a top-level menu for
the same Alt key. Every letter in "Service" (S/e/r/v/i/c) collides with a
control (Send / Receive / Volume / Connected peers / EQ / ...), so the menu
now uses Alt+J -- unused anywhere in the main window, so it opens reliably from
every tab. Same approach the Record menu already uses ("Record (Alt+&K)").
Kept Send on Alt+S (frequent control) rather than moving it.

New self-test "Menu shortcuts don't clash with controls": builds the main
window and asserts no top-level menu mnemonic collides with any control
mnemonic. The existing coverage audit couldn't catch this -- menu items are
ToolStripItems, not Controls, so its control walk never saw them. Confirms the
other four menus (File/Record/Options/Help) were already clean. Gate 31/31.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:17:39 +01:00
EdnunpandClaude Opus 4.8 2dad00c42f Offer the service on Win7+ by feature-detecting, not version-gating
Replaces the hardcoded "Windows 10 or newer" gate on the Service menu with a
runtime capability probe (ServiceCapability). It tries to load the .NET
service machinery (System.ServiceProcess) and offers the Service menu wherever
that succeeds -- including Windows 7 if the service layer loads there, which is
what Ed asked for ("install on Win7 or above"). If the assembly can't load on
an older/unsupported Windows, the probe catches it and the menu is simply
hidden, so such a machine can never be crashed by it.

Safety by construction: the reference to the service types lives only in the
NoInlining Probe method, so the assembly load is triggered by the CALL from
IsAvailable (inside its try/catch) and is catchable -- not during JIT of the
caller, which would be fatal. The startup path stays service-type-free via
ServiceEntry, so this probe runs only at window construction, never at launch.

Probe uses the parameterless ServiceController ctor: it forces the assembly to
load but touches no service and no SCM. (First cut read .ServiceName on a
made-up name, which actually queries the SCM and threw "service not found" --
the self-test caught that it was hiding the menu on Windows 11 too.)

New self-test "Service capability probe": available + cached + never throws on
the Win10/11 gate runner. Gate 30/30.

Note: this makes the service INSTALLABLE on Win7 wherever the layer loads; it
does not prove the service RUNS there (Session-0 capture on an unsupported
runtime) -- only a real test on the Win7 box can confirm that.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:55:16 +01:00
EdnunpandClaude Opus 4.8 9392cc1fcc Fix Win7 launch crash: keep service types off the startup path
Reported: RemSound no longer launches at all on Windows 7 since the
send-only service was added. Cause: RemSoundService derives from
ServiceBase (System.ServiceProcess), and Program.Main called
RemSoundService.RunAsService() directly in its body. The runtime resolves
every type a method names when it JIT-compiles that method -- so the moment
Main was compiled, at the very start of every launch and before any argument
was read, it force-loaded System.ServiceProcess. That assembly won't load on
Windows 7 under the .NET 10 runtime, so Main failed to compile and the app
died with no window. net10 has always been the target, so this was a pure
regression from the service work, not a framework change.

Fix:
- Move the whole service-verb dispatch into a separate ServiceEntry class.
  Program.Main now only calls it (a) after a cheap check that uses only
  inlined const verb strings, and (b) only when a service verb is actually
  present. A normal launch never JIT-compiles anything that names a service
  type, so System.ServiceProcess is never loaded. Verified empirically:
  a normal launch loads 107 modules, none of them System.ServiceProcess.
- Gate the Service menu to Windows 10+ (OperatingSystem.IsWindowsVersionAtLeast),
  mirroring how the "capture individual apps" feature is gated. On Win7/8 the
  menu isn't shown and no service code is reachable. Made the status-query
  handler defensive too, so a query failure can never crash the menu.

New self-test "Service verb gate": normal launches (no args, --silent,
--profile, --connect, --minimized, --config-dir) are never treated as a
service invocation; all five service verbs are recognised case-insensitively;
and deciding a normal launch loads no service assembly. Gate 29/29.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 08:41:56 +01:00
EdnunpandClaude Opus 4.8 26903950f7 Add "auto save non-read only profiles" preference (silent)
New global setting in Preferences -> General, right after "Browse for
profiles folder": a list "Auto-save non-read-only profiles" with Never /
Every 2 / 5 / 10 / 15 / 20 / 30 minutes. When enabled, RemSound periodically
saves the current profile, but ONLY if it is a real saved profile, is NOT
read-only, and has unsaved changes -- and it saves SILENTLY (no save cue,
no confirmation), so it never interrupts the user.

- AppConfig.AutoSaveNonReadOnlyMinutes (machine-wide, 0 = Never = default).
- SaveProfileTo gains a playCue flag; auto-save passes playCue: false.
- MainForm autoSaveTimer + ApplyAutoSaveTimer + ShouldAutoSave guard,
  re-applied live when the setting changes in Preferences.
- New self-test "Auto-save non-read-only profiles": options list, AppConfig
  persistence, the guard (read-only / blank / unchanged all skipped), and the
  timer turning on/off. Gate 28/28.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:32:17 +01:00
EdnunpandClaude Opus 4.8 71fb065233 Add make-tester-zip.ps1 for clean portable copies to hand to testers
Local checkpoint - NOT for public release.

Produces a zip that mirrors the publish folder (every current code fix, program files,
pdb, all runtimes, sounds, readme, Tolk, install scripts) with NONE of the developer's
personal state (no profiles/config/logs/recordings). Same safety approach as
build-release.ps1: publishes into a fresh never-run folder, then scans both the staged
files AND the finished zip and aborts if any personal data is present. Not a public
release (no tag/notes/GitHub) - a private portable copy for external testers.

Runs the build-and-test gate first by default (don't ship testers a broken build);
-SkipGate to bypass, -Output to pick the path (defaults to D:\Dropbox\remsound.zip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:27:08 +01:00
EdnunpandClaude Opus 4.8 4b2ecca960 Service: always-on update log + "View service update log" menu item
Local checkpoint - NOT for public release. Ed wants logging for the service updating.

- New ALWAYS-ON update log at ProgramData\RemSound\service\update.log (not gated on the
  service-logging toggle - updates are rare but important). It records the full sequence:
    * "update detected: newer RemSound.exe (5.3) found, running 5.2 - restarting"
    * the restarter's own "stopping" / "service started" (or "START FAILED - <reason>")
    * "update complete: now running version 5.3"
  The restart PowerShell writes its stop/start outcome itself, so the part that runs AFTER
  the old service process is gone - and any failed start - is still captured. A pending
  marker set at detection and consumed on the next start closes the loop (its absence flags
  a stuck update).
- Service menu gains "View service update log" to open it (friendly message if none yet).

Update log + pending-marker round-trip covered by the isolation self-test. Gate 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:59:40 +01:00
EdnunpandClaude Opus 4.8 d5767050f2 Service: show the running version in the Service menu (so a self-update is visible)
Local checkpoint - NOT for public release. Answers "how will we know if the service
updated itself?"

- The service records its version + start time on every OnStart into ServiceStore
  (ProgramData). A self-update restart therefore bumps the version and refreshes the
  start time.
- The Service menu status line now shows it: "Service: installed, running — version 5.3,
  running since 2 min ago". So you can see the running version at a glance, and a recent
  start with a bumped version is the self-update landing.
- Also logged on start ("service: OnStart, version X") for the service log.

Status round-trip covered by the isolation self-test. Gate 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:51:59 +01:00
EdnunpandClaude Opus 4.8 56dbd0b80f Service: pick up app updates by self-restarting onto the new binary
Local checkpoint - NOT for public release. Ed: an update should tear down the service,
update it, and restart it. It didn't - the updater has no service awareness.

Current (unchanged, verified correct): the auto-updater renames the install files aside
and copies the new ones in, which a running service TOLERATES (no failed swap). The old
version keeps streaming; the new files sit in place.

New: the service now adopts the update itself. Because it runs as SYSTEM (which has the
rights the non-elevated updater lacks), a 45s timer notices when a strictly-newer
RemSound.exe has landed next to it and restarts itself onto the new binary (detached
PowerShell Stop-Service+Start-Service). Loop-safe: only fires on a strictly-newer on-disk
version; any uncertainty (file mid-swap, unparseable version) means don't restart, and
after the restart on-disk == running so it never re-triggers.

Test "Service registration args" now also covers the version-comparison logic (newer =>
restart; same/older/missing => no restart). Gate 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:38:52 +01:00
EdnunpandClaude Opus 4.8 941e0df99f Service: log install/uninstall/start/stop/configure to the RemSound log
Local checkpoint - NOT for public release. ServiceAction and ConfigureServiceProfile
now write to the main app's log (gated on RemSound logging), so turning on logs in
RemSound captures the install request + result code and profile saves. (Windows also
records the install itself in the System Event Log, Event 7045, always.) Gate 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:30:55 +01:00
EdnunpandClaude Opus 4.8 92ed477ddf Service: harden for unattended running (auto-restart, findable logs, resume)
Local checkpoint - NOT for public release. A "what else does a service need" pass.

- AUTO-RESTART ON CRASH: DoInstall now sets sc failure actions (restart 5s/10s/then 60s,
  reset daily). Without this a crashed service stays dead until reboot - fatal for an
  always-on streamer.
- FINDABLE LOGS: --run-service redirects the service's data dir to the machine-wide
  ProgramData\RemSound\service location, so its log sits next to its profile instead of
  buried in the SYSTEM account's AppData.
- POWER RESUME: the service handles OnPowerEvent and re-opens capture on wake (audio
  devices re-initialise after sleep; the device-change watcher usually catches it, but a
  resume doesn't always fire an endpoint change, so we re-open explicitly).
- Start/stop already auto-log to the Windows Event Log via ServiceBase.

Test: "Service registration args" now also checks the audio-service dependency and the
auto-restart failure args. Gate 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:43:33 +01:00
EdnunpandClaude Opus 4.8 4f5265d8b0 Service: isolate the profile in ProgramData, out of all normal profile paths
Local checkpoint - NOT for public release. Ed: the service profile must never be
reachable except through the Service menu.

Also fixes a real bug: the service runs as SYSTEM, whose per-user data folder is NOT the
interactive user's - so a profile saved in the user's profiles folder (or AppConfig, both
per-user) was invisible to the service. It would have idled, never streaming.

- New RemSound.Core.ServiceStore: the service profile + its settings (logging) live in a
  MACHINE-WIDE ProgramData\RemSound\service location - same absolute path for the user
  (config dialog) and SYSTEM (service). Moved ServiceProfileName/ServiceLoggingEnabled off
  AppConfig (per-user) onto this store.
- ServiceSendHost.FromConfig + RemSoundService now read ServiceStore; ConfigureServiceProfile
  saves there (and migrates + deletes any profile left in the old user-folder location).
- Because it's no longer in the user's profiles folder, it can't appear in the startup
  picker, File->Open, Recent profiles, or the password manager (all of which read the user
  ProfileStore); the reserved-title filter in ListProfileTitles stays as belt-and-braces.
- Password button renamed "Set service profile password".
- New self-test "Service profile isolation": store is under ProgramData, the reserved title
  is filtered from the listing, and it round-trips through the machine-wide store.

Gate 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 08:23:10 +01:00
EdnunpandClaude Opus 4.8 f0b35b2b8c Tests: remove the opt-in real-service-lifecycle test
Local checkpoint - NOT for public release. Ed will test the real install/service and
the sound by hand, so the elevated (admin-only) install/start/stop/uninstall self-test
isn't worth keeping. Removed it and its IsAdministrator helper. The headless service
tests stay (parity, app-yield, send host, registration args) - those run in the gate and
guard the service against drifting from the main app. Gate 26/26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:51:47 +01:00
EdnunpandClaude Opus 4.8 4ee9cf6907 Tests: opt-in real Windows-service lifecycle (install/start/stop/uninstall)
Local checkpoint - NOT for public release.

New self-test "Service real lifecycle" drives the ACTUAL SCM end to end: install ->
report installed -> start -> report running -> stop -> report stopped -> uninstall ->
report gone. Needs admin, so it's OPT-IN via REMSOUND_TEST_SERVICE=1 and skips cleanly
in the normal unprivileged gate. It clears any leftover registration first (DoUninstall
stops a running copy, so a stray from an aborted run can't fail it) and always removes
the service afterwards, even on failure - so it never leaves a service pointing at a
throwaway exe. Run elevated: set REMSOUND_TEST_SERVICE=1 then RemSound.exe --selftest.

The app-yield takeover is already covered headlessly by "Service send host (stream +
yield)"; a full real end-to-end (installed service goes quiet when the app opens) needs
a live peer to observe and stays a manual check. Gate 26 passed, 1 skipped of 27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:41:26 +01:00
EdnunpandClaude Opus 4.8 ce45489b30 Tests: guard the service peer-port default matches the main app
Local checkpoint - NOT for public release. Adds a check that a peer with no explicit
port resolves to RemPacket.DefaultPeerDialPort (not the local audio port), guarding the
third audit divergence. Gate 26/26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:33:27 +01:00
EdnunpandClaude Opus 4.8 78a9aa6572 Service: fix divergences from the main app's send path
Local checkpoint - NOT for public release. Audited ServiceSendHost against MainForm's
send path (Ed: make the service reuse the same code, be just as stable). Three real
divergences found and fixed:

1. ENCRYPTION FINGERPRINT (critical): the host set sender.AudioKey but NOT
   sender.AudioFingerprint. The main app (RecomputeAudioCrypto) sets both, and the peer
   verifies the fingerprint before accepting a stream - so the service's encrypted audio
   would have been REJECTED at the far end. Now derives and sets both from the password.
2. OPUS FRAME: the main app applies EffectiveOpusFrameSamples (the "Small" send rate
   halves the Opus frame); the host passed the raw frame, so it would encode differently
   than the main app for the same profile. Now reuses MainForm.EffectiveOpusFrameSamples
   (made internal - same code, not a copy).
3. PEER PORT: send target fell back to the profile's LOCAL AudioPort; the correct default
   is RemPacket.DefaultPeerDialPort (what the main app's manual-peer path uses). Same value
   today but the right constant.

Reviewed and OK: sender defaults to WasapiOnly (no SetAudioMode needed); BuildSendSpecs
matches ApplySendSources for explicit-device profiles; default-device changes are covered
by the device-change watcher; direct-send (no relay/StartReceiving) is the intended v1 scope.

New self-test "Service sender parity" asserts key + fingerprint + effective Opus frame
match the main app. Gate 26/26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:13:48 +01:00
EdnunpandClaude Opus 4.8 4fb490b2d5 Service: use the main app's event-driven device watcher, not a poll
Local checkpoint - NOT for public release. Ed: don't add continuous background checks
(they've piled up before) - reuse the disconnect/reconnect mechanism the main app uses.

Replaced the 5s packet-flow health poll with AudioDeviceChangeNotifier - the SAME
event-driven watcher the main window uses. It fires only when a device is added/removed/
changes state or the default changes (nothing polls, nothing accumulates - it's a single
registered COM callback, disposed with the host). While the service intends to send, that
event re-opens capture, covering: the audio stack finishing coming up at boot, a device
plugged/unplugged, and the audio service restarting. Debounced (a hot-plug fires a burst).

Also dropped the per-tick Resume retry: now one start attempt per app-absence, then the
device watcher drives any re-open. The only remaining periodic thing is the tiny 1s
presence-token check for the app-yield (a mutex probe - allocates nothing, accumulates
nothing). Gate 25/25.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:04:26 +01:00