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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ec149ed09e
commit
a450cae66a
@@ -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
|
||||
}
|
||||
|
||||
@@ -806,12 +806,15 @@ internal static class SelfTest
|
||||
/// divergences found auditing the service against the main app.</summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>The lock-screen service's app-yield token: while a hold is active the service must see an
|
||||
|
||||
@@ -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 <see cref="CheckedListAccessibility"/>), 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 <see cref="Profile"/> clone; nothing is persisted until the caller acts on OK.
|
||||
/// </summary>
|
||||
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<AppRow>().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<string>().Distinct().ToList();
|
||||
@@ -303,14 +279,6 @@ internal sealed class ServiceProfileDialog : Form
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectCodec()
|
||||
{
|
||||
foreach (var item in codecBox.Items.OfType<CodecChoice>())
|
||||
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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
/// <summary>"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.</summary>
|
||||
public bool TightLatencyMode { get; set; } = true;
|
||||
/// <summary>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;
|
||||
|
||||
@@ -279,21 +279,9 @@ public sealed class RemSoundSettingsStore
|
||||
Save(s);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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.
|
||||
|
||||
/// <summary>Priority mode for the current profile. When true, the App's
|
||||
/// <c>PerformanceMode</c> helper drives every Win32 lever that elevates a process's
|
||||
|
||||
Reference in New Issue
Block a user