diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index bfb7787..08c040e 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -289,7 +289,6 @@ public sealed class MainForm : Form private readonly AccessibleCheckBox continuousTuneAsioBox = new() { Text = "Continuous auto-tune ASIO latency", AutoSize = true }; private readonly ListBox smoothnessBox = new() { Width = 420, Height = 200, IntegralHeight = false, AccessibleName = "Buffer smoothness (Alt+B)" }; private readonly ListBox artefactBox = new() { Width = 420, Height = 60, IntegralHeight = false, AccessibleName = "Artefact sound type (Alt+A) — controls how audio gaps sound" }; - private readonly AccessibleCheckBox tightLatencyBox = new() { AutoSize = true }; // Priority mode (per profile). Sits as the first control on the Audio profile tab, // ungrouped above the two GroupBoxes, so it's the first thing focus lands on when the // user Tabs into the tab. Toggling marks the profile dirty (the setting lives in @@ -1122,12 +1121,10 @@ public sealed class MainForm : Form // The receiver-side hook was removed in the 2026-05-06 cleanup since the resampler is // no longer in the receive path. The dialog checkbox label still says "Lock to audio // clock" but only affects the sender now. - var initialTightLatency = settings.LoadTightLatencyMode(); - sender.SetTightLatency(initialTightLatency); - // Log it so post-test analysis can correlate clicks with tight-latency state without - // having to infer from sender-engine restarts. Includes the audio mode because what - // "tight" means is mode-dependent (per-callback ASIO emission vs. WASAPI push-mode). - logFile.Event($"tight latency at startup: {(initialTightLatency ? "on" : "off")} (audio mode={settings.LoadAudioMode()})"); + // Lock to audio clock is always on now (no longer a user option) — put the sender into + // tight-latency mode unconditionally. + sender.SetTightLatency(true); + logFile.Event($"tight latency at startup: on (always) (audio mode={settings.LoadAudioMode()})"); // Priority mode (per-profile). Applies every PerformanceMode lever on first launch // under this profile so the OS doesn't start coasting before the user has tabbed @@ -4630,40 +4627,10 @@ public sealed class MainForm : Form panel.Controls.Add(codecAndSendLabel, 0, 0); panel.Controls.Add(codecRowPanel, 1, 0); - // === Row 1: Tight latency (sender-side, mode-dependent label) === - // Mnemonic was Alt+K until v1.5 (2026-05-15) when the Record menu took Alt+K at - // the menu-bar level. Replaced with Alt+D — the D in "au&dio" is naturally part of - // the word, no explicit "(Alt+...)" hint needed. Free on the Audio profile tab - // (no other Audio-profile control uses D). - var currentAudioModeForLabel = settings.LoadAudioMode(); - var tightLatencyText = currentAudioModeForLabel switch - { - AudioMode.WasapiOnly => "Lock to au&dio clock, WASAPI sender", - AudioMode.BothIndependent => "Lock to au&dio clock, WASAPI + ASIO senders", - _ => "Lock to au&dio clock", - }; - var tightLatencyAccessible = currentAudioModeForLabel switch - { - AudioMode.WasapiOnly => "Lock to audio clock (Alt+D) — sender uses the WASAPI capture event for timing instead of a Stopwatch tick. Tightens delay; brief clicks possible if the link can't keep up.", - AudioMode.BothIndependent => "Lock to audio clock (Alt+D) — both lanes tighten independently. WASAPI lane uses push-mode (single source); ASIO lane emits per callback. Brief clicks possible on either if the link can't keep up.", - _ => "Lock to audio clock (Alt+D) — sender-side timing tighten.", - }; - tightLatencyBox.Text = tightLatencyText; - tightLatencyBox.AccessibleName = tightLatencyAccessible; - tightLatencyBox.Checked = settings.LoadTightLatencyMode(); - tightLatencyBox.CheckedChanged += (_, _) => - { - settings.SaveTightLatencyMode(tightLatencyBox.Checked); - sender.SetTightLatency(tightLatencyBox.Checked); - logFile.Event($"tight latency changed to {(tightLatencyBox.Checked ? "on" : "off")} (audio mode={settings.LoadAudioMode()})"); - MarkProfileDirty(); - }; - var tightLatencyLabel = new Label { Text = tightLatencyText, AutoSize = true, Anchor = AnchorStyles.Left }; - tightLatencyLabel.Click += (_, _) => tightLatencyBox.Focus(); - var tightLatencyContainer = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; - tightLatencyContainer.Controls.Add(tightLatencyBox); - panel.Controls.Add(tightLatencyLabel, 0, 1); - panel.Controls.Add(tightLatencyContainer, 1, 1); + // "Lock to audio clock" (sender uses the WASAPI capture event for timing instead of a Stopwatch + // tick) is now ALWAYS ON and is no longer a user option (Ed, 2026-07-17: nobody ever runs with it + // off — off just adds delay). The sender is put into tight-latency mode unconditionally at startup; + // the old per-profile checkbox is gone. group.Controls.Add(panel); } @@ -9493,8 +9460,8 @@ public sealed class MainForm : Form // EffectiveOpusFrameSamples is samples-per-channel at 48 kHz; ÷ 48 → ms, ÷ 2 → half-frame. return EffectiveOpusFrameSamples(item.Codec, item.OpusFrameSamples, rate) / 96.0; } - // PCM - if (settings.LoadTightLatencyMode() && settings.LoadAudioMode() == AudioMode.AsioOnly) + // PCM. Lock to audio clock is always on now, so ASIO-only means per-callback emission. + if (settings.LoadAudioMode() == AudioMode.AsioOnly) { return 0.5; // per-callback ASIO send → ~one ASIO buffer, hard to know without driver introspection } diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index caba615..10fff7f 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -806,12 +806,15 @@ internal static class SelfTest /// divergences found auditing the service against the main app. private static string? ServiceSenderParity() { + // The profile deliberately carries the WRONG audio transport (raw PCM, broadcast frame, Standard + // rate) to prove the service IGNORES it and forces its known-good live config (Opus, 2.5 ms frame, + // Small packets, lock-to-clock) — the fix for the crackly-service report (Ed, 2026-07-17). var profile = new Profile { Title = "parity", - Codec = AudioTransportCodec.Opus, - OpusFrameSamplesPerChannel = 960, // broadcast - SendRate = SendRate.Tight, // "Small" — should halve the Opus frame to 480 + Codec = AudioTransportCodec.Pcm, + OpusFrameSamplesPerChannel = 960, + SendRate = SendRate.Standard, WasapiSendMode = "devices", }; profile.SelectedWasapiSendOutputs.Add("fake-device-id"); // a source so ApplyProfile proceeds @@ -820,17 +823,20 @@ internal static class SelfTest profile.Password = RemSoundCrypto.Obfuscate(pw); using var host = new ServiceSendHost(() => profile); - Check(host.ApplyProfile(profile), "ApplyProfile should proceed with a source + peer + password"); + // ApplyProfile sets the sender's crypto + codec BEFORE it opens the (fake) device; a device-open + // failure is now swallowed inside ApplyProfile, so this returns and the config is readable. + host.ApplyProfile(profile); var cfg = host.SenderConfigForTest; Check(cfg.Key is { Length: > 0 } && cfg.Key.SequenceEqual(RemSoundCrypto.DeriveKey(pw)), "the service must set the audio key = DeriveKey(password)"); Check(cfg.Fingerprint is { Length: > 0 } && cfg.Fingerprint.SequenceEqual(RemSoundCrypto.Fingerprint(pw)), "the service must set the audio FINGERPRINT = Fingerprint(password), or the peer rejects the stream"); - Check(cfg.Codec == AudioTransportCodec.Opus, "codec must round-trip to the sender"); - Check(cfg.Frame == MainForm.EffectiveOpusFrameSamples(AudioTransportCodec.Opus, 960, SendRate.Tight) && cfg.Frame == 480, - $"the Small send rate must halve the Opus frame like the main app (got {cfg.Frame})"); - return "key + fingerprint + effective Opus frame match the main app"; + Check(cfg.Codec == AudioTransportCodec.Opus, + $"the service must FORCE Opus regardless of the profile codec (got {cfg.Codec})"); + Check(cfg.Frame == 120, + $"the service must force the 2.5 ms live Opus frame (120 samples), regardless of the profile (got {cfg.Frame})"); + return "service forces Opus + 2.5ms frame + lock-to-clock regardless of profile; crypto matches"; } /// The lock-screen service's app-yield token: while a hold is active the service must see an diff --git a/src/RemSound.App/ServiceProfileDialog.cs b/src/RemSound.App/ServiceProfileDialog.cs index 99da079..2fa2ead 100644 --- a/src/RemSound.App/ServiceProfileDialog.cs +++ b/src/RemSound.App/ServiceProfileDialog.cs @@ -7,8 +7,9 @@ namespace RemSound.App; /// The "Configure RemSound service profile" dialog: a self-contained, modal editor for the send-only /// service profile, built to match the main window — same house controls, same layout rows, and the /// same screen-reader wiring on the lists (via ), so it reads and -/// behaves exactly like the real tabs. Tab order mirrors the main window: Connectivity, then Audio send, -/// then Audio profile. Send-only and WASAPI-only: no receive controls, no ASIO, no "Send my audio" +/// behaves exactly like the real tabs. Two tabs: Connectivity, then Audio send. The Audio profile tab +/// was removed (2026-07-17) — codec/packet/lock-to-clock are fixed to known-good live values so nobody +/// mis-tunes the service. Send-only and WASAPI-only: no receive controls, no ASIO, no "Send my audio" /// toggle. Edits a clone; nothing is persisted until the caller acts on OK. /// internal sealed class ServiceProfileDialog : Form @@ -37,10 +38,13 @@ internal sealed class ServiceProfileDialog : Form private readonly Label inputsStatus = new() { AutoSize = true, Text = "No input device selected." }; private MnemonicLabel? sendModeLabel, outputsLabel, appsLabel, inputsLabel; - // --- Audio profile tab --- - private readonly ComboBox codecBox = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 360, AccessibleName = "Audio codec (Alt+C)" }; - private readonly ListBox sendRateBox = new() { Width = 360, Height = 40, IntegralHeight = false, AccessibleName = "Packet size (Alt+P)" }; - private readonly AccessibleCheckBox tightLatencyBox = new() { AutoSize = true }; + // No "Audio profile" tab: the service always sends Opus at the live-jamming frame (2.5 ms), Small + // packets, and locked to the audio clock — the settings Ed found sound good. They're forced in + // SaveToProfile below and again at runtime in ServiceSendHost, so there's nothing to misconfigure + // (Ed, 2026-07-17: removed the tab; nobody should be touching these). + private const AudioTransportCodec ServiceCodec = AudioTransportCodec.Opus; + private const int ServiceOpusFrameSamples = 120; // 2.5 ms at 48 kHz — live latency + private const SendRate ServiceSendRate = SendRate.Tight; // "Small" packets // --- Button row --- private readonly Button saveButton = new() { Text = "&Save and Close", AutoSize = true, DialogResult = DialogResult.OK }; @@ -69,10 +73,10 @@ internal sealed class ServiceProfileDialog : Form AccessibleName = "Configure RemSound service profile"; if (Theme.AppIcon is { } icon) Icon = icon; - // Tabs in the main window's order: Connectivity, Audio send, Audio profile. + // Two tabs only now: Connectivity and Audio send. The Audio profile tab was removed — the codec, + // packet size and lock-to-clock are fixed to known-good live-jamming values (see fields above). BuildConnectivityTab(); BuildAudioSendTab(); - BuildAudioProfileTab(); BuildButtonRow(); LoadFromProfile(); @@ -168,33 +172,6 @@ internal sealed class ServiceProfileDialog : Form tabs.TabPages.Add(page); } - private void BuildAudioProfileTab() - { - var page = new TabPage("Audio profile"); - var panel = NewPanel(); - - // Exact same codec choices, packet-size items and lock-to-clock label as the main window. - codecBox.Items.AddRange(new object[] - { - new CodecChoice("PCM 48K 24 bit — uncompressed", AudioTransportCodec.Pcm, 0), - new CodecChoice("Opus, broadcast quality — loss tolerant", AudioTransportCodec.Opus, 960), - new CodecChoice("Opus, live latency — for jamming and monitoring", AudioTransportCodec.Opus, 120), - }); - sendRateBox.Items.Add("Standard (5 ms PCM, 10/20 ms Opus)"); - sendRateBox.Items.Add("Small (2.5 ms PCM, 5/10 ms Opus, LAN only)"); - tightLatencyBox.Text = "Lock to au&dio clock, WASAPI sender"; - tightLatencyBox.AccessibleName = "Lock to audio clock (Alt+D) — sender uses the WASAPI capture event for timing instead of a Stopwatch tick. Tightens delay; brief clicks possible if the link can't keep up."; - - FormLayoutRows.AddRow(panel, 0, "Audio &codec (Alt+C)", codecBox, c => c.Focus()); - FormLayoutRows.AddRow(panel, 1, "&Packet size (Alt+P)", sendRateBox, c => c.Focus()); - var tightWrap = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; - tightWrap.Controls.Add(tightLatencyBox); - panel.Controls.Add(tightWrap, 1, 2); - - page.Controls.Add(panel); - tabs.TabPages.Add(page); - } - private void BuildButtonRow() { var outer = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2 }; @@ -237,10 +214,6 @@ internal sealed class ServiceProfileDialog : Form sendAllAppsBox.Checked = working.SendAllApplications; PopulateAppsList(); - SelectCodec(); - sendRateBox.SelectedIndex = Math.Clamp((int)working.SendRate, 0, sendRateBox.Items.Count - 1); - tightLatencyBox.Checked = working.TightLatencyMode; - peersList.Items.Clear(); var allPeers = working.RememberedPeers.Concat(working.SelectedConnectedPeers) .Where(p => !string.IsNullOrWhiteSpace(p)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); @@ -263,9 +236,12 @@ internal sealed class ServiceProfileDialog : Form working.SendAllApplications = sendAllAppsBox.Checked; working.SelectedSendApplications = appsList.CheckedItems.OfType().Select(a => a.ProcessName).Distinct().ToList(); - if (codecBox.SelectedItem is CodecChoice c) { working.Codec = c.Codec; working.OpusFrameSamplesPerChannel = c.OpusFrameSamples; } - working.SendRate = (SendRate)Math.Max(0, sendRateBox.SelectedIndex); - working.TightLatencyMode = tightLatencyBox.Checked; + // Audio profile is fixed for the service (no tab): Opus live-jamming frame, Small packets, locked + // to the audio clock. These are also re-forced at runtime in ServiceSendHost. + working.Codec = ServiceCodec; + working.OpusFrameSamplesPerChannel = ServiceOpusFrameSamples; + working.SendRate = ServiceSendRate; + working.TightLatencyMode = true; // Ticked peers are the ones the service sends to; keep every listed peer as remembered. working.SelectedConnectedPeers = peersList.CheckedItems.OfType().Distinct().ToList(); @@ -303,14 +279,6 @@ internal sealed class ServiceProfileDialog : Form } } - private void SelectCodec() - { - foreach (var item in codecBox.Items.OfType()) - if (item.Codec == working.Codec && (working.Codec == AudioTransportCodec.Pcm || item.OpusFrameSamples == working.OpusFrameSamplesPerChannel)) - { codecBox.SelectedItem = item; return; } - if (codecBox.Items.Count > 0) codecBox.SelectedIndex = 0; - } - private void ApplySendModeVisibility() { var supported = ProcessLoopbackCapture.IsSupported; diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index f75a6e5..f781945 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -311,19 +311,30 @@ public sealed class ServiceSendHost : IDisposable var plainPassword = string.IsNullOrEmpty(profile.Password) ? "" : RemSoundCrypto.Deobfuscate(profile.Password); sender.AudioKey = string.IsNullOrEmpty(plainPassword) ? null : RemSoundCrypto.DeriveKey(plainPassword); sender.AudioFingerprint = string.IsNullOrEmpty(plainPassword) ? null : RemSoundCrypto.Fingerprint(plainPassword); - // Opus frame size follows the send rate the same way the main app does (the "Small" rate - // halves the Opus frame) — otherwise the service would encode at a different frame than the - // main app would for the identical profile. - sender.ConfigureCodec(profile.Codec, MainForm.EffectiveOpusFrameSamples(profile.Codec, profile.OpusFrameSamplesPerChannel, profile.SendRate)); - sender.SetSendRate(profile.SendRate); - sender.SetTightLatency(profile.TightLatencyMode); + // The service's audio transport is FIXED to the known-good live-jamming config, regardless of + // what the profile carries (the config dialog no longer exposes these — Ed, 2026-07-17). Raw + // PCM sounded hideous over the service; Opus at the 2.5 ms live frame with Small packets and + // lock-to-clock is what sounds right. Forcing it here means a stale or hand-edited profile can + // never put the service back on a bad codec. + const AudioTransportCodec serviceCodec = AudioTransportCodec.Opus; + const int serviceOpusFrameSamples = 120; // 2.5 ms at 48 kHz + const SendRate serviceSendRate = SendRate.Tight; // "Small" packets + sender.ConfigureCodec(serviceCodec, MainForm.EffectiveOpusFrameSamples(serviceCodec, serviceOpusFrameSamples, serviceSendRate)); + sender.SetSendRate(serviceSendRate); + sender.SetTightLatency(true); // lock to audio clock — always on // Arm the full set to begin with (nothing is known-dead yet); RefreshSendArming then prunes any // peer the heartbeat can't reach and re-arms it when it recovers. allEndpoints = endpoints.ToArray(); armedSignature = null; sender.SetReceivers(endpoints); sender.Configure(specs); - sender.Start(); + // A device-open failure at Start must NOT throw out of the service loop. With lock-to-clock + // always on, the WASAPI lane uses push-mode, which opens the endpoint synchronously and throws + // (e.g. ArgumentException) if the device id is invalid or the device has vanished. Swallow it: + // presence still comes up, and the device-change watcher / self-heal re-open the capture when a + // good device appears. Without this guard a disappeared device would crash the whole service. + try { sender.Start(); } + catch (Exception ex) { log?.Invoke($"service: capture start failed ({ex.GetType().Name}: {ex.Message}) — presence up; will re-open when a device is available"); } SwapMeterDevices(specs); // endpoint-meter readers for the deaf-capture detector // Instant self-heal trigger: session-created notification on the default render device. // Recreated per apply so it re-points at the current default after a device change. diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs index b3fee49..eee527f 100644 --- a/src/RemSound.Core/Profile.cs +++ b/src/RemSound.Core/Profile.cs @@ -107,7 +107,11 @@ public sealed class Profile [JsonPropertyName("OpusFrameMilliseconds")] public int OpusFrameSamplesPerChannel { get; set; } = 480; public int SendRateRaw { get; set; } = (int)SendRate.Standard; - public bool TightLatencyMode { get; set; } + /// "Lock to audio clock" — sender uses the WASAPI capture event for timing. As of 2026-07-17 + /// this is ALWAYS ON and no longer user-configurable (the app forces it true at startup and the + /// service forces it per stream); the field is kept only for profile-file backward compatibility. + /// Defaults true so any freshly created profile reflects the real behaviour. + public bool TightLatencyMode { get; set; } = true; /// True if this profile asks Windows to keep the RemSound process in /// high-priority mode while it's running — CPU scheduling, power management, memory /// priority, working-set lock, and MMCSS thread priority all elevated. Off by default; diff --git a/src/RemSound.Core/RemSoundSettingsStore.cs b/src/RemSound.Core/RemSoundSettingsStore.cs index e86ce56..d882c0f 100644 --- a/src/RemSound.Core/RemSoundSettingsStore.cs +++ b/src/RemSound.Core/RemSoundSettingsStore.cs @@ -279,21 +279,9 @@ public sealed class RemSoundSettingsStore Save(s); } - /// Tight-latency mode toggle. Sender-side only as of 2026-05-06 (the receiver no - /// longer has a resampler to bypass). In WasapiOnly + single source mode the sender swaps - /// from the timer-driven MixingEngine to the audio-clock-locked PushModeWasapiBackend; in - /// AsioOnly + PCM mode the sender emits one packet per ASIO callback instead of accumulating - /// to the chosen frame size. Saves a few ms of send-side latency at the cost of brief - /// clicks if the link can't keep up. Off by default. - public bool LoadTightLatencyMode(bool defaultValue = false) => - Try(() => Load()?.TightLatencyMode) ?? defaultValue; - - public void SaveTightLatencyMode(bool value) - { - var s = Load() ?? new Settings(); - s.TightLatencyMode = value; - Save(s); - } + // Tight-latency mode ("Lock to audio clock") is ALWAYS ON as of 2026-07-17 — no longer a user option, + // so the Load/Save accessors were removed. The app forces the sender tight at startup and the service + // per stream. The Settings cache still round-trips Profile.TightLatencyMode for file compatibility. /// Priority mode for the current profile. When true, the App's /// PerformanceMode helper drives every Win32 lever that elevates a process's