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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>