Commit Graph
16 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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
EdnunpandClaude Opus 4.8 a7742fe2ec Service: self-heal if capture isn't ready (or a device drops) at start
Local checkpoint - NOT for public release. Answers "what if it looks for sound
devices before their services are up?"

Two layers now:
- depend= Audiosrv/AudioEndpointBuilder (prior commit) makes Windows start the service
  only once the audio services are running.
- Self-heal in the run loop: while it should be sending, if no packets have flowed for
  ~5s then no capture is actually running (endpoints not fully ready at boot, a device
  dropped, or the audio service restarted) - it re-opens the capture. And while NOT
  sending it already re-tries every second, so a slow-to-appear audio stack or a device
  that returns later is picked up automatically.

So even if the service races ahead of the endpoints being fully enumerated, it keeps
retrying/re-opening until audio actually flows, rather than sitting silent. Gate 25/25.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:52:16 +01:00
EdnunpandClaude Opus 4.8 e648bee531 Lock-screen service: spike + headless send host + app-yield coordination
Local checkpoint - NOT for public release. First increment of the send-only
Windows service feature (design in memory/project_remsound_service).

THE SPIKE PASSED: the send engine runs fully headless (no window, no message
pump) and streams, proven by a real self-test — the one genuine unknown that
gated the whole feature. Also proves the app-yield model end to end.

What's in this increment (all headless, all tested, 19/19 gate):
- AppConfig: ServiceProfileName + ServiceLoggingEnabled (machine-wide).
- InteractivePresence (Core): the cross-session app-yield token. App holds a
  Global\ mutex for its lifetime; the service checks it and yields while an
  interactive app is present, resuming when it closes OR crashes (OS frees the
  mutex). Name-parameterised internal seams for isolated testing.
- ServiceSendHost (App): loads a send-only profile and streams it to its peers,
  WASAPI-only, no ASIO/receive. ApplyProfile/Suspend/Resume + a RunLoop that
  drives them from the presence token with a settle delay. v1 sends to direct
  peer addresses (LAN/port-forwarded); NAT/relay discovery stays the app's job.
- Program.cs: the interactive app now acquires the presence token at startup so
  a future service yields to it.
- Tests: "Service app-yield token" (held=present, released=absent) and "Service
  send host (headless stream + yield)" — streams a captured device to a local
  receiver over loopback, verifies start/suspend/resume, then drives the full
  RunLoop against the token (held=suspended, released=resumes-and-flows).

Still to come (later increments): the --run-service entry + Windows-service
registration, the Service menu, the 3-tab config dialog, updater integration,
docs. None user-facing yet, so nothing deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 14:59:31 +01:00