Release v3.7: device-change smoothing, auto-tune spike rejection, wider mic detector, forensic logging
Coalesce capture-engine rebuilds (CompositeCaptureBackend): a swap-triggering source change now arms a 250ms debounce timer and re-arms on each further change, so a flap or quick reconfiguration produces ONE rebuild to the final state instead of a burst (Andre's 16:40 four-rebuilds-in-33s crackle). In-place updates still apply immediately; a pending rebuild whose target flaps back is cancelled. Auto-tune (TickRoute) now keys off the SECOND-highest arrival-gap/render-gap second in the lookback window instead of the single worst, so a lone ~1s OS/driver stall no longer balloons the buffer to the 200ms cap (the 16:51 trim burst); sustained jitter still reacts at full speed. Logs both gap-max (true peak) and gap-used (value acted on). Mic-privacy detector widened: also catches a per-app Deny aimed at this exe under ConsentStore\microphone\NonPackaged\<exe>, the HKLM NonPackaged gate, and the Group-Policy/MDM force-deny (AppPrivacy LetAppsAccessMicrophone=2) — the block shapes that silence WASAPI capture while ASIO sails past, and that the old three-value check missed. Forensic instrumentation so the next log proves what happened: capPeak= (loudest pre-encode sample, per lane, on the diag line), mic-privacy verdict logged at startup, ui: capture tick/untick events, and device-event: lines for Windows endpoint changes. Docs: mic-privacy + auto-tune sections updated in readme.html, MANUAL.md regenerated, About-box changelog and RELEASE_NOTES for v3.7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -333,7 +333,7 @@ Tick any combination across the three lists. RemSound mixes them together into o
|
||||
|
||||
> **Capturing your speakers can cause an echo loop.** If you tick the same device both in “WASAPI outputs to send” and in “WASAPI outputs for received sound”, then the received sound plays out of that device, gets captured again, and gets sent back. The other person ends up hearing their own voice on a delay. Don't tick the same device on both sides at once.
|
||||
|
||||
> **If your microphone sends silence:** Windows can block desktop apps from using the microphone, and when it does, RemSound's mic capture still switches on but only sends silence — so you look like you're sending, but the other person hears nothing. RemSound watches for this: when you tick a microphone in **WASAPI inputs to send** while Windows is blocking it — or load a profile that already has one ticked — a message pops up telling you, with the exact two settings to turn on — open Windows Settings → Privacy & security → Microphone, then turn on both _Microphone access_ and _Let desktop apps access your microphone_. (ASIO inputs aren't affected, because ASIO talks straight to the hardware and bypasses that Windows privacy gate.) It doesn't change anything you receive — only sending your own mic.
|
||||
> **If your microphone sends silence:** Windows can block desktop apps from using the microphone, and when it does, RemSound's mic capture still switches on but only sends silence — so you look like you're sending, but the other person hears nothing. RemSound watches for this: when you tick a microphone in **WASAPI inputs to send** while Windows is blocking it — or load a profile that already has one ticked — a message pops up telling you, with the exact two settings to turn on — open Windows Settings → Privacy & security → Microphone, then turn on both _Microphone access_ and _Let desktop apps access your microphone_. The check also catches the sneakier kinds of block: one aimed at RemSound alone in that same Settings page's per-app list, and one set by an administrator or workplace policy — that last kind doesn't show up as a switch you can flip, so if the warning says a policy is involved, it needs whoever manages the computer to lift it. (ASIO inputs aren't affected, because ASIO talks straight to the hardware and bypasses that Windows privacy gate.) It doesn't change anything you receive — only sending your own mic.
|
||||
|
||||
## 8. Audio profile tab
|
||||
|
||||
@@ -631,7 +631,7 @@ An ASIO driver chosen (WASAPI and ASIO both running)| Both paths tighten indepen
|
||||
|
||||
### Continuous auto-tune
|
||||
|
||||
The **Continuous auto-tune latency** checkbox hands the latency value over to RemSound itself. When it's on, RemSound watches how evenly packets are arriving, every few seconds, and nudges the latency target up if it's seeing late packets, or down if the network has been calm. The companion **Auto-tune latency interval (Alt+I)** combo box sets how often it re-checks — **3, 5, 10, 15, or 30 seconds**. Faster values react quickly to a change in the network but can feel a bit twitchy. Think of continuous auto-tune as a hands-off way to keep the cushion the right size as your network changes through the session.
|
||||
The **Continuous auto-tune latency** checkbox hands the latency value over to RemSound itself. When it's on, RemSound watches how evenly packets are arriving, every few seconds, and nudges the latency target up if it's seeing late packets, or down if the network has been calm. It deliberately ignores a single one-off stall — the kind a driver or Windows hiccup causes once and never again — and only raises the cushion when late audio keeps arriving, so one brief blip doesn't balloon your latency for the rest of the session. The companion **Auto-tune latency interval (Alt+I)** combo box sets how often it re-checks — **3, 5, 10, 15, or 30 seconds**. Faster values react quickly to a change in the network but can feel a bit twitchy. Think of continuous auto-tune as a hands-off way to keep the cushion the right size as your network changes through the session.
|
||||
|
||||
If you turn auto-tune off, the latency value just stays wherever it last was.
|
||||
|
||||
|
||||
+23
-17
@@ -1,39 +1,45 @@
|
||||
# RemSound v3.6
|
||||
# RemSound v3.7
|
||||
|
||||
A reliability release centred on updating. RemSound now installs its own updates instead of handing the job to a Windows script — so updates that used to quietly fail on some machines go through, and a failed update can never leave you half-installed.
|
||||
A smoothness-and-diagnosis release: changing devices mid-session no longer crackles, a one-off computer hiccup no longer balloons your latency, the microphone-privacy warning catches the hidden kinds of block, and the log now records enough to prove what happened when something goes wrong.
|
||||
|
||||
## Updating is far more reliable
|
||||
## Changing devices mid-session is smoother
|
||||
|
||||
RemSound used to finish an update by handing the file-swap to a small Windows batch script. On some machines that step would silently fail — you'd "update" and still be on the old version, sometimes with nothing on screen to say why.
|
||||
When you change capture devices — ticking sources on or off, or bringing an ASIO driver in or out — RemSound used to rebuild its sound engine immediately on every single change. A quick run of changes meant several rebuilds back to back, each one an audible crackle. Now RemSound **waits a moment for you to finish and rebuilds once**, to the final state. Reconfiguring no longer fights you.
|
||||
|
||||
**RemSound now installs updates itself, in its own code:**
|
||||
## The auto-tuner ignores one-off hiccups
|
||||
|
||||
- It runs the install from a fresh copy of the new version in a temporary folder, so nothing in the program folder is locked while the swap happens.
|
||||
- It waits for the old copy to fully close, then moves the old files aside and copies the new ones in — **retrying** if a file is briefly held open (by a sync app, say) rather than giving up.
|
||||
- **If anything goes wrong, it puts your previous version back exactly as it was** and reopens it. A failed update can never leave a broken, half-installed RemSound.
|
||||
- Every step is written to a plain `updater.log` next to the program, so if a problem ever does recur there's a real reason to read, not a blank.
|
||||
Continuous auto-tune sizes your latency cushion from how late packets are arriving. It used to react to the **single worst moment** it had seen — so one brief stall (the kind a driver or Windows hiccup causes once and never again) could balloon the cushion to its maximum and disrupt audio for far longer than the hiccup itself. It now only raises the cushion when late audio **keeps coming** — sustained jitter still gets a fast response, a lone blip gets ignored.
|
||||
|
||||
Your settings, profiles, logs and cue sounds are never touched by any of this.
|
||||
## The mic-privacy warning catches hidden blocks
|
||||
|
||||
## More warnings come to the front
|
||||
Windows can block an app's microphone in ways that don't show up as the two obvious switches: a per-app block aimed at one program alone, or a block set by an administrator or workplace policy with no visible switch at all. When one of those is in place, the mic silently sends nothing — and RemSound's warning used to miss both kinds. It now **catches them too**, so "my mic sends silence and nothing warned me" should be gone.
|
||||
|
||||
Every important notice now surfaces in front with focus, even when RemSound is minimised to the tray. v3.6 adds the last two that didn't: the **"RemSound is already running"** dialog (and its follow-up message), and the **what's-new note shown after an update** that installed while RemSound was tucked away.
|
||||
## The log can now prove what happened
|
||||
|
||||
Four additions to RemSound's diagnostic log, aimed squarely at the questions we couldn't answer from past logs:
|
||||
|
||||
- **How loud is what you're sending** (`capPeak` each second) — so silence versus real audio is now a fact in the log, not a guess.
|
||||
- **The mic-privacy check's verdict** at every startup — whether or not it warned.
|
||||
- **Every device tick/untick you make**, and **every device change Windows reports** — so when the device set changes mid-session, the log shows exactly what drove it.
|
||||
- Auto-tune now logs both the worst moment it saw and the value it actually acted on, so you can see when it ignored a one-off spike.
|
||||
|
||||
Nothing about these is audible — they just make the next bug report solvable from the first log.
|
||||
|
||||
## Compatibility
|
||||
|
||||
**v3.6 talks to v3.3, v3.4 and v3.5 with no trouble** — the over-the-network format hasn't changed, so you don't have to update both ends at once. (Everyone still needs to be on **v3.3 or newer**, because that's where end-to-end encryption came in.)
|
||||
**v3.7 talks to v3.3 through v3.6 with no trouble** — the over-the-network format hasn't changed, so you don't have to update both ends at once. (Everyone still needs **v3.3 or newer**, where end-to-end encryption came in.)
|
||||
|
||||
## Install
|
||||
|
||||
1. Download `RemSound-v3.6.zip` from this release.
|
||||
1. Download `RemSound-v3.7.zip` from this release.
|
||||
2. Close RemSound.
|
||||
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your settings, profiles, logs, recordings, or sounds.
|
||||
4. Run `RemSound.exe`.
|
||||
|
||||
## Upgrading
|
||||
|
||||
**From v1.9 onward:** Help → Check for updates works — it will fetch and install v3.6 automatically, and if you've ticked "Check for updates on startup" and "Silently install updates" it installs itself shortly after launch.
|
||||
**From v3.6:** Help → Check for updates installs v3.7 with the new in-app updater — and if it ever can't finish, it puts v3.6 back exactly as it was.
|
||||
|
||||
Note that the hop *to* v3.6 still uses your current version's updater. The new, more reliable in-app installer only takes over **once you're on v3.6**. So on the rare machine where auto-update has been failing — exactly the problem this release fixes — download `RemSound-v3.6.zip` and extract it over your folder by hand, just this once. Every update after that uses the new installer.
|
||||
**From v1.9–v3.5:** Check for updates works, but it uses your current version's older updater for this one hop. If auto-update has been failing on your machine, install by hand using the steps above — every update after that uses the new installer.
|
||||
|
||||
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it installing updates at all — install v3.6 by hand using the steps above.
|
||||
**v1.8 and earlier:** the auto-updater in those versions can't install updates at all — install by hand using the steps above.
|
||||
|
||||
+2
-2
@@ -374,7 +374,7 @@ ul, ol { padding-left: 1.4em; }
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>If your microphone sends silence:</strong> Windows can block desktop apps from using the microphone, and when it does, RemSound's mic capture still switches on but only sends silence — so you look like you're sending, but the other person hears nothing. RemSound watches for this: when you tick a microphone in <strong>WASAPI inputs to send</strong> while Windows is blocking it — or load a profile that already has one ticked — a message pops up telling you, with the exact two settings to turn on — open Windows Settings → Privacy & security → Microphone, then turn on both <em>Microphone access</em> and <em>Let desktop apps access your microphone</em>. (ASIO inputs aren't affected, because ASIO talks straight to the hardware and bypasses that Windows privacy gate.) It doesn't change anything you receive — only sending your own mic.
|
||||
<strong>If your microphone sends silence:</strong> Windows can block desktop apps from using the microphone, and when it does, RemSound's mic capture still switches on but only sends silence — so you look like you're sending, but the other person hears nothing. RemSound watches for this: when you tick a microphone in <strong>WASAPI inputs to send</strong> while Windows is blocking it — or load a profile that already has one ticked — a message pops up telling you, with the exact two settings to turn on — open Windows Settings → Privacy & security → Microphone, then turn on both <em>Microphone access</em> and <em>Let desktop apps access your microphone</em>. The check also catches the sneakier kinds of block: one aimed at RemSound alone in that same Settings page's per-app list, and one set by an administrator or workplace policy — that last kind doesn't show up as a switch you can flip, so if the warning says a policy is involved, it needs whoever manages the computer to lift it. (ASIO inputs aren't affected, because ASIO talks straight to the hardware and bypasses that Windows privacy gate.) It doesn't change anything you receive — only sending your own mic.
|
||||
</div>
|
||||
|
||||
<h2 id="tab-profile">8. Audio profile tab</h2>
|
||||
@@ -677,7 +677,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
|
||||
|
||||
<h3>Continuous auto-tune</h3>
|
||||
|
||||
<p>The <strong>Continuous auto-tune latency</strong> checkbox hands the latency value over to RemSound itself. When it's on, RemSound watches how evenly packets are arriving, every few seconds, and nudges the latency target up if it's seeing late packets, or down if the network has been calm. The companion <strong>Auto-tune latency interval (Alt+I)</strong> combo box sets how often it re-checks — <strong>3, 5, 10, 15, or 30 seconds</strong>. Faster values react quickly to a change in the network but can feel a bit twitchy. Think of continuous auto-tune as a hands-off way to keep the cushion the right size as your network changes through the session.</p>
|
||||
<p>The <strong>Continuous auto-tune latency</strong> checkbox hands the latency value over to RemSound itself. When it's on, RemSound watches how evenly packets are arriving, every few seconds, and nudges the latency target up if it's seeing late packets, or down if the network has been calm. It deliberately ignores a single one-off stall — the kind a driver or Windows hiccup causes once and never again — and only raises the cushion when late audio keeps arriving, so one brief blip doesn't balloon your latency for the rest of the session. The companion <strong>Auto-tune latency interval (Alt+I)</strong> combo box sets how often it re-checks — <strong>3, 5, 10, 15, or 30 seconds</strong>. Faster values react quickly to a change in the network but can feel a bit twitchy. Think of continuous auto-tune as a hands-off way to keep the cushion the right size as your network changes through the session.</p>
|
||||
|
||||
<p>If you turn auto-tune off, the latency value just stays wherever it last was.</p>
|
||||
|
||||
|
||||
@@ -20,6 +20,28 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v3.7
|
||||
|
||||
Changing audio devices mid-session is smoother. A quick
|
||||
run of device changes now settles for a moment and
|
||||
rebuilds the sound engine once, instead of several times
|
||||
in a row — so reconfiguring no longer crackles.
|
||||
|
||||
The latency auto-tuner keeps its head when the computer
|
||||
hiccups. A single brief stall no longer makes it balloon
|
||||
the buffer; it only raises the cushion when late audio
|
||||
keeps coming.
|
||||
|
||||
The microphone-privacy warning now also catches blocks
|
||||
aimed at RemSound alone, and blocks set by an
|
||||
administrator policy — kinds of block Windows applies
|
||||
without showing you a switch.
|
||||
|
||||
RemSound's log now records the loudness of what you're
|
||||
sending, the mic-privacy check's verdict, and every
|
||||
device change — so if something goes wrong, the log can
|
||||
prove what happened instead of leaving us guessing.
|
||||
|
||||
RemSound v3.6
|
||||
|
||||
Updating is far more reliable. RemSound now installs its
|
||||
|
||||
@@ -1010,6 +1010,7 @@ public sealed class MainForm : Form
|
||||
sendInputDevicesList.ItemCheck += (_, args) =>
|
||||
{
|
||||
if (suppressDeviceCheckChange) return;
|
||||
logFile.Event($"ui: capture (WASAPI mic) '{sendInputDevicesList.Items[args.Index]}' {(args.NewValue == CheckState.Checked ? "ticked" : "unticked")}");
|
||||
BeginInvoke(ApplyAudioRuntime);
|
||||
MarkProfileDirty();
|
||||
// Heads-up when the user ticks a WASAPI mic ON but Windows is blocking desktop-app
|
||||
@@ -1026,7 +1027,7 @@ public sealed class MainForm : Form
|
||||
WireCheckedListAccessibility(asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, "ASIO receive output channel");
|
||||
WireCheckedListAccessibility(asioSendDevicesList, asioSendDevicesStatusLabel, "ASIO send channel");
|
||||
asioReceiveOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); } };
|
||||
asioSendDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } };
|
||||
asioSendDevicesList.ItemCheck += (_, args) => { if (!suppressDeviceCheckChange) { logFile.Event($"ui: capture (ASIO) '{asioSendDevicesList.Items[args.Index]}' {(args.NewValue == CheckState.Checked ? "ticked" : "unticked")}"); BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } };
|
||||
// Profile-management button click wirings retired 2026-05-08 — File menu items now
|
||||
// call SaveProfileAs() / UpdateExistingProfile() / hotkeyController.ShowKeyboardShortcutsDialog
|
||||
// / trayController.Minimize() directly. See BuildFileMenu.
|
||||
@@ -3672,6 +3673,7 @@ public sealed class MainForm : Form
|
||||
private void OnAudioEndpointsChanged()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
logFile.Event("device-event: Windows reported an audio endpoint change (refresh queued)");
|
||||
try
|
||||
{
|
||||
BeginInvoke(new Action(() =>
|
||||
@@ -3821,20 +3823,28 @@ public sealed class MainForm : Form
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Reads Windows' microphone privacy setting and returns true when DESKTOP apps (which
|
||||
/// RemSound is) are blocked from the mic. When blocked, WASAPI capture still opens but returns
|
||||
/// pure silence, so the user "sends" but the peer hears nothing. Registry-based: the
|
||||
/// CapabilityAccessManager ConsentStore "Value" is "Allow"/"Deny"; either the general per-user
|
||||
/// gate or the NonPackaged (desktop-app) gate set to Deny blocks us. Best-effort — any failure
|
||||
/// returns false so a registry hiccup never stops the user enabling their mic.</summary>
|
||||
/// <summary>Reads Windows' microphone privacy settings and returns true when desktop apps — or
|
||||
/// THIS app specifically — are blocked from the mic. When blocked, WASAPI capture still opens but
|
||||
/// returns pure silence; ASIO bypasses this gate entirely, which is why the same mic can be live
|
||||
/// in ASIO yet dead here. Checks, in order: the per-user and machine top-level gate and the
|
||||
/// NonPackaged (all desktop apps) gate; a per-app Deny aimed at this exe under
|
||||
/// <c>NonPackaged\<exe></c> (the case the old single-value check missed, where one app is
|
||||
/// denied while the top-level value still says Allow); and a Group-Policy / MDM force-deny
|
||||
/// (<c>LetAppsAccessMicrophone=2</c>, which the Settings UI doesn't even show). Best-effort — any
|
||||
/// failure returns false so a registry hiccup never stops the user enabling their mic.</summary>
|
||||
private static bool IsMicrophoneBlockedByWindowsPrivacy()
|
||||
{
|
||||
try
|
||||
{
|
||||
const string consent = @"Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone";
|
||||
const string nonPackaged = consent + @"\NonPackaged";
|
||||
return IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, consent)
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, consent + @"\NonPackaged")
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.LocalMachine, consent);
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, nonPackaged)
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.LocalMachine, consent)
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.LocalMachine, nonPackaged)
|
||||
|| IsThisExeDeniedUnderNonPackaged(Microsoft.Win32.Registry.CurrentUser, nonPackaged)
|
||||
|| IsThisExeDeniedUnderNonPackaged(Microsoft.Win32.Registry.LocalMachine, nonPackaged)
|
||||
|| IsMicPolicyForceDenied();
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -3845,9 +3855,40 @@ public sealed class MainForm : Form
|
||||
private static bool IsConsentDenied(Microsoft.Win32.RegistryKey root, string subKey)
|
||||
{
|
||||
using var key = root.OpenSubKey(subKey);
|
||||
// The ConsentStore "Value" is a REG_SZ "Allow"/"Deny". `as string` yields null for any other
|
||||
// type or a missing value, so anything that isn't an explicit "Deny" reads as allowed.
|
||||
return string.Equals(key?.GetValue("Value") as string, "Deny", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>True if a per-app override under NonPackaged denies THIS exe specifically (its child
|
||||
/// key is named by the exe's full path with '\' replaced by '#'). We match by exe filename rather
|
||||
/// than reconstructing the exact encoding, so a deny on a different app never false-warns us.</summary>
|
||||
private static bool IsThisExeDeniedUnderNonPackaged(Microsoft.Win32.RegistryKey root, string nonPackagedKey)
|
||||
{
|
||||
var exeName = System.IO.Path.GetFileName(Environment.ProcessPath ?? "");
|
||||
if (string.IsNullOrEmpty(exeName)) return false;
|
||||
using var key = root.OpenSubKey(nonPackagedKey);
|
||||
if (key is null) return false;
|
||||
foreach (var childName in key.GetSubKeyNames())
|
||||
{
|
||||
if (childName.IndexOf(exeName, StringComparison.OrdinalIgnoreCase) < 0) continue;
|
||||
using var child = key.OpenSubKey(childName);
|
||||
if (string.Equals(child?.GetValue("Value") as string, "Deny", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>True if a Group Policy / MDM rule force-denies microphone access to apps
|
||||
/// (<c>HKLM\SOFTWARE\Policies\Microsoft\Windows\AppPrivacy\LetAppsAccessMicrophone == 2</c>). This
|
||||
/// sits below the Settings toggles, so the user can't see or undo it without policy access — and
|
||||
/// the old check never looked here at all.</summary>
|
||||
private static bool IsMicPolicyForceDenied()
|
||||
{
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Policies\Microsoft\Windows\AppPrivacy");
|
||||
return key?.GetValue("LetAppsAccessMicrophone") is int v && v == 2;
|
||||
}
|
||||
|
||||
/// <summary>One-shot, OK-only message telling the user Windows is blocking desktop-app mic
|
||||
/// access (so their mic would send silence) and exactly which two toggles to turn on. Shown
|
||||
/// when they tick a WASAPI mic on while the block is in place.</summary>
|
||||
@@ -3873,13 +3914,16 @@ public sealed class MainForm : Form
|
||||
private void MaybeWarnMicBlockedOnStartup()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
if (!IsMicrophoneBlockedByWindowsPrivacy()) return;
|
||||
var blocked = IsMicrophoneBlockedByWindowsPrivacy();
|
||||
var anyWasapiMicChecked = false;
|
||||
for (var i = 0; i < sendInputDevicesList.Items.Count; i++)
|
||||
{
|
||||
if (sendInputDevicesList.GetItemChecked(i)) { anyWasapiMicChecked = true; break; }
|
||||
}
|
||||
if (anyWasapiMicChecked) WarnMicrophoneBlockedByWindowsPrivacy();
|
||||
// Log the detector's verdict every startup so a silent-mic session is no longer ambiguous —
|
||||
// we can see whether RemSound thought Windows was blocking the mic, not just whether it warned.
|
||||
logFile.Event($"mic-privacy: windows-blocks-desktop-mic={blocked} wasapiMicTicked={anyWasapiMicChecked}");
|
||||
if (blocked && anyWasapiMicChecked) WarnMicrophoneBlockedByWindowsPrivacy();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -5336,6 +5380,7 @@ public sealed class MainForm : Form
|
||||
var stepRawCapXB = sender.TakeMaxSenderRawCaptureStepCrossBuffer();
|
||||
var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer();
|
||||
var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB;
|
||||
var capPeak = sender.TakeMaxSenderPreEncodePeak();
|
||||
var clippedNow = sender.ClippedSampleCount;
|
||||
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
|
||||
var stepPostDecXB = receiver.TakeMaxPostDecodeStepCrossBuffer();
|
||||
@@ -5371,7 +5416,7 @@ public sealed class MainForm : Form
|
||||
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
|
||||
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
|
||||
$"filtErr={filteredErrorFrames:0.0}f " +
|
||||
$"stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
|
||||
$"capPeak={capPeak:0.000} stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
|
||||
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
|
||||
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
|
||||
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
|
||||
@@ -6917,22 +6962,33 @@ public sealed class MainForm : Form
|
||||
|
||||
var sampleCount = Math.Min(LookbackSeconds, recentMaxGaps.Count);
|
||||
var skip = recentMaxGaps.Count - sampleCount;
|
||||
var observedGap = 0;
|
||||
// Track the TWO highest arrival-gap seconds, not just the worst. A single transient spike —
|
||||
// one bad second from an OS/driver hiccup that doesn't recur — used to drive the whole
|
||||
// recommendation (one 1046ms gap pushed the buffer straight to the 200ms cap and shed a big
|
||||
// trim burst). Using the SECOND-highest requires the jitter to persist across >=2 seconds
|
||||
// before it counts, while still honouring sustained jitter at full speed. The true peak is
|
||||
// still logged for diagnosis; we fall back to the single value when there's only one sample.
|
||||
int gapPeak = 0, gapSecond = 0;
|
||||
var i = 0;
|
||||
foreach (var gap in recentMaxGaps)
|
||||
{
|
||||
if (i++ < skip) continue;
|
||||
if (gap > observedGap) observedGap = gap;
|
||||
if (gap > gapPeak) { gapSecond = gapPeak; gapPeak = gap; }
|
||||
else if (gap > gapSecond) { gapSecond = gap; }
|
||||
}
|
||||
var observedGap = sampleCount >= 2 ? gapSecond : gapPeak;
|
||||
|
||||
var observedRenderCb = RenderPeriodFloorMs;
|
||||
// Same lone-spike rejection for the render-callback gap.
|
||||
int rcbPeak = RenderPeriodFloorMs, rcbSecond = RenderPeriodFloorMs;
|
||||
var rcbSkip = recentRenderCbGaps.Count - sampleCount;
|
||||
var rcbI = 0;
|
||||
foreach (var rcb in recentRenderCbGaps)
|
||||
{
|
||||
if (rcbI++ < rcbSkip) continue;
|
||||
if (rcb > observedRenderCb) observedRenderCb = rcb;
|
||||
if (rcb > rcbPeak) { rcbSecond = rcbPeak; rcbPeak = rcb; }
|
||||
else if (rcb > rcbSecond) { rcbSecond = rcb; }
|
||||
}
|
||||
var observedRenderCb = sampleCount >= 2 ? rcbSecond : rcbPeak;
|
||||
|
||||
var codecFloor = (int)Math.Ceiling(1.5 * frameMs);
|
||||
var jitterBased = observedGap + observedRenderCb + SafetyMarginMs;
|
||||
@@ -6963,7 +7019,7 @@ public sealed class MainForm : Form
|
||||
suppressFlag = false;
|
||||
}
|
||||
var logPrefix = string.IsNullOrEmpty(routeLabel) ? "continuous auto-tune" : $"continuous auto-tune {routeLabel}";
|
||||
logFile.Event($"{logPrefix}: gap-max={observedGap}ms renderCb={observedRenderCb}ms over {sampleCount}s recommended={recommended}ms capped={capped}ms prev={current}ms applied={clamped}ms frame={frameMs}ms");
|
||||
logFile.Event($"{logPrefix}: gap-max={gapPeak}ms gap-used={observedGap}ms renderCb={observedRenderCb}ms over {sampleCount}s recommended={recommended}ms capped={capped}ms prev={current}ms applied={clamped}ms frame={frameMs}ms");
|
||||
}
|
||||
|
||||
// UpdateTuneButtonEnabled + TuneLatencyAsync retired alongside the one-shot Tune button.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
tag_name on the latest GitHub release; bump it on every public release. The
|
||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||
is what the About dialog and the updater both read. -->
|
||||
<Version>3.6.0</Version>
|
||||
<Version>3.7.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -179,6 +179,15 @@ public sealed class AudioSender : IDisposable
|
||||
public float TakeMaxPreEncodeStepWasapiLane() => defaultLane.TakeMaxPreEncodeStep();
|
||||
public float TakeMaxPreEncodeStepAsioLane() => asioLane.TakeMaxPreEncodeStep();
|
||||
|
||||
/// <summary>Loudest absolute pre-encode sample across both lanes since the last call (resets on
|
||||
/// read). ~0 means we're sending silence; surfaced on the diag line as capPeak.</summary>
|
||||
public float TakeMaxSenderPreEncodePeak()
|
||||
{
|
||||
var a = defaultLane.TakeMaxPreEncodePeak();
|
||||
var b = asioLane.TakeMaxPreEncodePeak();
|
||||
return a > b ? a : b;
|
||||
}
|
||||
|
||||
// Cross-buffer (boundary) and within-buffer (content) split — see AudioStepProbe for the
|
||||
// diagnostic distinction. Used by the per-second diag logger to emit two extra columns so
|
||||
// an offline log inspection can tell a real audio transient apart from a buffer-boundary
|
||||
|
||||
@@ -51,6 +51,15 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
|
||||
private List<CaptureSourceSpec> asioSpecs = [];
|
||||
private bool started;
|
||||
|
||||
// Coalesces capture-lane rebuilds. A push<->mix backend swap tears down and restarts the whole
|
||||
// WASAPI lane (a brief audible gap); when the capture set flaps, doing that per-change produces a
|
||||
// burst of restarts. We defer the swap by RebuildDebounceMs and re-arm on each change, so a run of
|
||||
// changes collapses into one rebuild. Only the swap path is debounced — in-place source updates
|
||||
// still apply immediately. The timer callback and all of these fields are mutated under `gate`.
|
||||
private System.Threading.Timer? rebuildTimer;
|
||||
private IReadOnlyList<CaptureSourceSpec>? pendingRebuildSpecs;
|
||||
private const int RebuildDebounceMs = 250;
|
||||
|
||||
public CompositeCaptureBackend(AudioMode mode, string? asioDriverName, Action<ReadOnlyMemory<float>> onMixedSamples, Action<ReadOnlyMemory<float>>? onAsioLaneSamples, AsioCaptureBackend? injectedAsio, Action<string>? onDiagnostic = null, bool useTightLatencyWasapi = false)
|
||||
{
|
||||
this.onMixedSamples = onMixedSamples;
|
||||
@@ -212,32 +221,75 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
|
||||
}
|
||||
var (newWasapi, newAsio) = SplitSpecs(specs);
|
||||
|
||||
// If push-mode applicability changes (single WASAPI source toggled on/off), the
|
||||
// backend has to swap. PushModeWasapiBackend supports only one source. Full
|
||||
// restart is acceptable here — changing source count mid-session is rare.
|
||||
// A push-mode swap (single WASAPI source toggled on/off) means tearing the whole WASAPI
|
||||
// lane down and rebuilding it — a brief audible gap. When the capture set flaps (a device
|
||||
// coming and going, or a quick reconfiguration), doing that on every change produces a
|
||||
// burst of restarts. Coalesce instead: stash the target and (re)arm a short timer so a run
|
||||
// of changes collapses into ONE rebuild to the final state. PushModeWasapiBackend still
|
||||
// supports only one source; this just defers the swap, it doesn't change the end result.
|
||||
var wouldBePush = useTightLatencyWasapi && newWasapi.Count == 1;
|
||||
var isPush = wasapi is PushModeWasapiBackend;
|
||||
if (wouldBePush != isPush)
|
||||
{
|
||||
onDiagnostic?.Invoke($"wasapi backend: source count changed ({wasapiSpecs.Count}→{newWasapi.Count}), restarting to switch backend");
|
||||
StopInternal();
|
||||
Start(specs);
|
||||
pendingRebuildSpecs = specs;
|
||||
(rebuildTimer ??= new System.Threading.Timer(OnRebuildDue)).Change(RebuildDebounceMs, System.Threading.Timeout.Infinite);
|
||||
onDiagnostic?.Invoke($"wasapi backend: source-count change ({wasapiSpecs.Count}→{newWasapi.Count}) — coalescing rebuild in {RebuildDebounceMs}ms");
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasapi is not null && !SpecsEqual(wasapiSpecs, newWasapi))
|
||||
// No swap needed: the live backend takes these specs in place (cheap, no glitch). This
|
||||
// also resolves a pending rebuild whose target flapped back to the current backend shape.
|
||||
CancelPendingRebuild();
|
||||
ApplyInPlace(newWasapi, newAsio);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyInPlace(List<CaptureSourceSpec> newWasapi, List<CaptureSourceSpec> newAsio)
|
||||
{
|
||||
if (wasapi is not null && !SpecsEqual(wasapiSpecs, newWasapi))
|
||||
{
|
||||
wasapi.UpdateSources(newWasapi);
|
||||
wasapiSpecs = newWasapi;
|
||||
}
|
||||
if (asio is not null && !SpecsEqual(asioSpecs, newAsio))
|
||||
{
|
||||
asio.UpdateSources(newAsio);
|
||||
asioSpecs = newAsio;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fires ~<see cref="RebuildDebounceMs"/> after the last swap-triggering change. Applies
|
||||
/// the final capture set with a single rebuild — or, if the set flapped back to the current
|
||||
/// backend shape during the window, just an in-place update with no rebuild at all. Holds gate.</summary>
|
||||
private void OnRebuildDue(object? state)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var specs = pendingRebuildSpecs;
|
||||
pendingRebuildSpecs = null;
|
||||
if (specs is null || !started) return;
|
||||
var (newWasapi, newAsio) = SplitSpecs(specs);
|
||||
var wouldBePush = useTightLatencyWasapi && newWasapi.Count == 1;
|
||||
var isPush = wasapi is PushModeWasapiBackend;
|
||||
if (wouldBePush != isPush)
|
||||
{
|
||||
wasapi.UpdateSources(newWasapi);
|
||||
wasapiSpecs = newWasapi;
|
||||
onDiagnostic?.Invoke($"wasapi backend: applying coalesced rebuild → {newWasapi.Count} wasapi source(s)");
|
||||
StopInternal();
|
||||
Start(specs);
|
||||
}
|
||||
if (asio is not null && !SpecsEqual(asioSpecs, newAsio))
|
||||
else
|
||||
{
|
||||
asio.UpdateSources(newAsio);
|
||||
asioSpecs = newAsio;
|
||||
ApplyInPlace(newWasapi, newAsio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelPendingRebuild()
|
||||
{
|
||||
pendingRebuildSpecs = null;
|
||||
rebuildTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
|
||||
}
|
||||
|
||||
private string ModeLabel() => mode switch
|
||||
{
|
||||
AudioMode.WasapiOnly => "fast (WASAPI direct)",
|
||||
@@ -252,6 +304,7 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
|
||||
|
||||
private void StopInternal()
|
||||
{
|
||||
CancelPendingRebuild();
|
||||
if (!started) return;
|
||||
try { wasapi?.Stop(); } catch { /* ignore */ }
|
||||
// ASIO child is NEVER stopped here — it's the persistent instance owned by AudioSender
|
||||
@@ -264,6 +317,7 @@ internal sealed class CompositeCaptureBackend : ICaptureBackend
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
try { rebuildTimer?.Dispose(); } catch { /* ignore */ }
|
||||
try { wasapi?.Dispose(); } catch { /* ignore */ }
|
||||
// ASIO child not disposed — see StopInternal above.
|
||||
}
|
||||
|
||||
@@ -76,6 +76,13 @@ internal sealed class SenderLane
|
||||
public float TakeMaxPreEncodeStepCrossBuffer() => preEncodeStepProbe.TakeMaxCrossBuffer();
|
||||
public float TakeMaxPreEncodeStepWithinBuffer() => preEncodeStepProbe.TakeMaxWithinBuffer();
|
||||
|
||||
// Loudest absolute sample seen on this lane's pre-encode buffer since the last drain (resets on
|
||||
// read), surfaced on the diag line. ~0 = we are sending silence (mic blocked / muted / wrong
|
||||
// endpoint); a clear non-zero = real audio is reaching the encoder. This is the signal level the
|
||||
// log never had — which is exactly why a "mic sends silence" report couldn't be confirmed from it.
|
||||
private float preEncodePeak;
|
||||
public float TakeMaxPreEncodePeak() { var p = preEncodePeak; preEncodePeak = 0f; return p; }
|
||||
|
||||
// Which render route this lane announces in its format packets. The receiver reads the
|
||||
// Lane byte on the wire and tags the matching SessionPlayout, which makes PlayoutEngine
|
||||
// route the lane's audio to the corresponding per-route IWaveProvider surface (lane
|
||||
@@ -190,6 +197,14 @@ internal sealed class SenderLane
|
||||
// <see cref="preEncodeStepProbe"/> field comment for why this isn't shared with the
|
||||
// other lane in BothIndependent.
|
||||
preEncodeStepProbe.ScanStereo(span);
|
||||
// Capture-level peak alongside the discontinuity probe — the loudest sample about to be sent.
|
||||
var peak = preEncodePeak;
|
||||
for (var s = 0; s < span.Length; s++)
|
||||
{
|
||||
var a = span[s] < 0f ? -span[s] : span[s];
|
||||
if (a > peak) peak = a;
|
||||
}
|
||||
preEncodePeak = peak;
|
||||
|
||||
switch (owner.Codec)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user