Release v3.5: USB-card recovery, per-card adaptive buffer, one user folder, audit fixes
- Recover an unplugged/replugged output sound card automatically (issue #5): detect the dead WASAPI device and remember the receive-output selection so it re-ticks and re-opens when the card returns. - Adaptive per-card WASAPI buffer target sized to each card's pull chunk, held stable so it never flits about under CPU/network load. - Consolidate all per-user data (config, profiles, logs, sounds) into one "user settings and logs" folder; migrate every older layout; exclude it from the updater so custom cue sounds now survive updates. - Mic-privacy detector: warn once when a Windows-blocked mic is switched on, or a profile loads with one already on. - All warning/notice dialogs now come to the foreground even when minimised. - Apply volume + mute on profile load (were saved but not restored). - Crash-safe (atomic) profile/config saves. - Fix two resource leaks (push-mode capture MMDevice; UPnP DeviceFound handler). - Remove dead code (baseline-diff machinery, dead ASIO probes, no-op stubs). - Docs: readme.html, MANUAL.md, About-box changelog and release notes for v3.5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
946e6f4be4
commit
4dbe9a47a0
@@ -20,6 +20,37 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v3.5
|
||||
|
||||
Recover a sound card you unplug: if a USB sound card
|
||||
you're listening through is pulled out and plugged back
|
||||
in, RemSound re-opens it on its own and audio resumes —
|
||||
no need to re-tick it.
|
||||
|
||||
Each sound card now gets the right audio cushion
|
||||
automatically. RemSound sizes it to the card, so one that
|
||||
needs a little more gets it without any fiddling, and a
|
||||
fast card stays tight.
|
||||
|
||||
A microphone-privacy heads-up: if Windows is blocking
|
||||
microphone access and you switch a mic on, RemSound warns
|
||||
you once, so you're not left wondering why no one can
|
||||
hear you.
|
||||
|
||||
Warnings always come to the front now, even when RemSound
|
||||
is tucked away in the system tray — so you never miss one.
|
||||
|
||||
Everything this machine keeps for you — settings,
|
||||
profiles, logs and your cue sounds — now lives in one
|
||||
tidy folder called "user settings and logs". It moves
|
||||
there automatically the first time you run this version.
|
||||
From now on, updates never touch that folder, so any
|
||||
custom cue sounds you put there are safe.
|
||||
|
||||
Also: your volume and mute now come back correctly when
|
||||
you load a profile, plus a batch of under-the-hood
|
||||
reliability and tidy-up work.
|
||||
|
||||
RemSound v3.4
|
||||
|
||||
Quick profile switch: a new global hotkey pops up a
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Shows a dialog FRONT-AND-CENTRE with keyboard focus, WITHOUT disturbing the main window — it can
|
||||
/// stay minimised in the tray the whole time. We give the dialog a momentary, top-most, off-taskbar
|
||||
/// 1×1 owner window at screen centre and force THAT owner to the foreground (the AttachThreadInput
|
||||
/// dance bypasses Windows' focus-stealing lock), so the modal dialog opens on top with focus and a
|
||||
/// screen reader lands on it wherever RemSound happens to be sitting. Used for every warning/notice
|
||||
/// the app raises (mic-privacy, Realtek, About-after-update, config-moved), so a minimised RemSound
|
||||
/// never leaves a blind user with a dialog dinging away behind everything.
|
||||
/// </summary>
|
||||
internal static class ForegroundDialog
|
||||
{
|
||||
/// <summary>Run <paramref name="show"/> with a foreground 1×1 owner; returns its result.</summary>
|
||||
public static T Show<T>(Func<IWin32Window, T> show)
|
||||
{
|
||||
var area = Screen.PrimaryScreen?.WorkingArea ?? new Rectangle(0, 0, 800, 600);
|
||||
using var owner = new Form
|
||||
{
|
||||
ShowInTaskbar = false,
|
||||
FormBorderStyle = FormBorderStyle.None,
|
||||
StartPosition = FormStartPosition.Manual,
|
||||
Size = new Size(1, 1),
|
||||
Location = new Point(area.X + area.Width / 2, area.Y + area.Height / 2),
|
||||
TopMost = true,
|
||||
};
|
||||
owner.Show();
|
||||
ForceForeground(owner.Handle);
|
||||
try { return show(owner); }
|
||||
finally { try { owner.Close(); } catch { /* ignore */ } }
|
||||
}
|
||||
|
||||
/// <summary>Void convenience overload.</summary>
|
||||
public static void Show(Action<IWin32Window> show) =>
|
||||
Show<object?>(owner => { show(owner); return null; });
|
||||
|
||||
/// <summary>Force <paramref name="hWnd"/> to the foreground even when RemSound isn't the active
|
||||
/// app. A plain SetForegroundWindow from a background process is refused by Windows; attaching
|
||||
/// our input queue to the current foreground thread for the call lifts that restriction. The
|
||||
/// owner is top-most regardless, so this is belt-and-braces for focus.</summary>
|
||||
private static void ForceForeground(IntPtr hWnd)
|
||||
{
|
||||
try
|
||||
{
|
||||
var foreThread = GetWindowThreadProcessId(GetForegroundWindow(), out _);
|
||||
var thisThread = GetCurrentThreadId();
|
||||
if (foreThread != 0 && foreThread != thisThread)
|
||||
{
|
||||
AttachThreadInput(foreThread, thisThread, true);
|
||||
try
|
||||
{
|
||||
BringWindowToTop(hWnd);
|
||||
SetForegroundWindow(hWnd);
|
||||
}
|
||||
finally { AttachThreadInput(foreThread, thisThread, false); }
|
||||
}
|
||||
else
|
||||
{
|
||||
BringWindowToTop(hWnd);
|
||||
SetForegroundWindow(hWnd);
|
||||
}
|
||||
}
|
||||
catch { /* best-effort; the owner is top-most anyway */ }
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
||||
[DllImport("user32.dll")] private static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
|
||||
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr hWnd);
|
||||
}
|
||||
+151
-124
@@ -383,6 +383,11 @@ public sealed class MainForm : Form
|
||||
// Windows audio endpoint-change notifier — drives the debounced device-list refresh. Null until
|
||||
// wired in the constructor; disposed in FormClosing (which unregisters the COM callback).
|
||||
private AudioDeviceChangeNotifier? deviceChangeNotifier;
|
||||
|
||||
// Receive-output device IDs the user/profile selected — kept even while a device is unplugged,
|
||||
// so a card that returns is silently re-ticked and re-opened (issue #5: recover after USB
|
||||
// unplug). Receive-only: the send lists deliberately don't persist selection (AudioDeviceCatalog).
|
||||
private readonly HashSet<string> rememberedReceiveOutputIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
// Debounce timer for ASIO driver listbox selection. See SelectedIndexChanged handler
|
||||
// wiring for the full rationale. 300 ms is long enough to coalesce arrow-key bursts
|
||||
// (NVDA users typically press a few keys in quick succession to scan through items),
|
||||
@@ -537,13 +542,6 @@ public sealed class MainForm : Form
|
||||
/// when non-null — it deserialises the JSON from this exact path, not from the active
|
||||
/// store's base directory. Lets Open profile work for files saved outside that folder.</summary>
|
||||
public string? NextProfilePathToLoad { get; private set; }
|
||||
// Baseline JSON snapshot of "what the loaded profile was at open / after the last save".
|
||||
// OnFormClosing compares the current state's JSON to this; if they differ, prompt the
|
||||
// user. Captured ~3 s after profile-apply (or app start for blank template) so async
|
||||
// peer-reconnects have settled into the baseline. Null until that timer fires; if it's
|
||||
// null at close (e.g. user closed within 3 s of opening) we skip the prompt — treating
|
||||
// very-fast-close as "user knew what they wanted".
|
||||
private string? baselineProfileJson;
|
||||
// Set true by MarkProfileDirty() when the user actively changes something. Used as a
|
||||
// fast-path hint — we still do the JSON diff at close to be sure, but this lets us skip
|
||||
// the diff entirely when no user action has happened. Cleared on save and on profile load.
|
||||
@@ -993,11 +991,37 @@ public sealed class MainForm : Form
|
||||
sendMyAudioCheckbox.CheckedChanged += (_, _) => OnStreamingCheckboxChanged(sendMyAudioCheckbox);
|
||||
volumeBar.Scroll += (_, _) => { receiver.Volume = volumeBar.Value / 100f; MarkProfileDirty(); };
|
||||
WireCheckedListAccessibility(receiveOutputDevicesList, receiveOutputDevicesStatusLabel, "receive output device");
|
||||
receiveOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); } };
|
||||
receiveOutputDevicesList.ItemCheck += (_, e) =>
|
||||
{
|
||||
if (suppressDeviceCheckChange) return;
|
||||
// Track the user's intent so a card that's later unplugged is re-ticked + re-opened when
|
||||
// it returns (issue #5). See ReapplyRememberedReceiveOutputs.
|
||||
if (receiveOutputDevicesList.Items[e.Index] is AudioDeviceChoice c && c.DeviceId is { } rid)
|
||||
{
|
||||
if (e.NewValue == CheckState.Checked) rememberedReceiveOutputIds.Add(rid);
|
||||
else rememberedReceiveOutputIds.Remove(rid);
|
||||
}
|
||||
BeginInvoke(ApplyReceiveDevices);
|
||||
MarkProfileDirty();
|
||||
};
|
||||
WireCheckedListAccessibility(sendOutputDevicesList, sendOutputDevicesStatusLabel, "output device");
|
||||
WireCheckedListAccessibility(sendInputDevicesList, sendInputDevicesStatusLabel, "input device");
|
||||
sendOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } };
|
||||
sendInputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } };
|
||||
sendInputDevicesList.ItemCheck += (_, args) =>
|
||||
{
|
||||
if (suppressDeviceCheckChange) return;
|
||||
BeginInvoke(ApplyAudioRuntime);
|
||||
MarkProfileDirty();
|
||||
// Heads-up when the user ticks a WASAPI mic ON but Windows is blocking desktop-app
|
||||
// microphone access — capture would open but silently send nothing. Deferred so the
|
||||
// tick commits first and the modal doesn't re-enter ItemCheck. Skipped while a profile
|
||||
// is being applied: the startup check (MaybeWarnMicBlockedOnStartup) owns the warning
|
||||
// then, so a launch into a blocked-mic profile doesn't pop it twice.
|
||||
if (!applyingProfile && args.NewValue == CheckState.Checked && IsMicrophoneBlockedByWindowsPrivacy())
|
||||
{
|
||||
BeginInvoke(new Action(WarnMicrophoneBlockedByWindowsPrivacy));
|
||||
}
|
||||
};
|
||||
// ASIO list accessibility + ItemCheck handlers — same patterns as the WASAPI ones.
|
||||
WireCheckedListAccessibility(asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, "ASIO receive output channel");
|
||||
WireCheckedListAccessibility(asioSendDevicesList, asioSendDevicesStatusLabel, "ASIO send channel");
|
||||
@@ -1176,17 +1200,11 @@ public sealed class MainForm : Form
|
||||
// checkboxes, audio port, volume, ticked peers). Done here AFTER device lists are
|
||||
// populated by LoadAudioDevices(). Settings-shaped fields (codec, hotkeys, etc.)
|
||||
// were already pushed into the in-memory settings cache in the constructor.
|
||||
// ApplyPendingProfileToControls() schedules its own baseline capture; for the
|
||||
// blank-template case (no pendingProfile) we schedule it here.
|
||||
if (pendingProfile is null) ScheduleBaselineCapture();
|
||||
ApplyPendingProfileToControls();
|
||||
// The profile-switch cue is played ON CLICK by the switch entry points (Recent menu,
|
||||
// quick switch, File open) — NOT here. A fresh launch into the first profile must stay
|
||||
// silent: hearing the switch cue and then the connect cue at startup is confusing
|
||||
// (Ed, 2026-06-08). So the rebuilt form never replays it.
|
||||
// Show/hide the Update vs Save-as buttons based on whether we're on a loaded
|
||||
// profile or the blank template.
|
||||
UpdateProfileButtonsVisibility();
|
||||
// Andre's app gets focus inside the active tab page for free because his form is
|
||||
// a MODAL DIALOG (ShowDialog) — WinForms' modal-dialog focus semantics walk the
|
||||
// chain TabControl → active TabPage → first child. Our form is the main window,
|
||||
@@ -1300,6 +1318,8 @@ public sealed class MainForm : Form
|
||||
MaybeShowWhatsNewAfterUpdate();
|
||||
if (IsDisposed) return;
|
||||
MaybeWarnAboutRealtekAsio();
|
||||
if (IsDisposed) return;
|
||||
MaybeWarnMicBlockedOnStartup();
|
||||
}
|
||||
|
||||
/// <summary>If the user opted in (<see cref="AppConfig.ShowWhatsNewAfterUpdate"/>) and the
|
||||
@@ -1325,7 +1345,7 @@ public sealed class MainForm : Form
|
||||
{
|
||||
logFile.Event($"what's new: opening About after update {cfg.LastWhatsNewVersion} -> {current}");
|
||||
using var dlg = new AboutDialog();
|
||||
dlg.ShowDialog(this);
|
||||
ForegroundDialog.Show(owner => dlg.ShowDialog(owner));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2253,8 +2273,8 @@ public sealed class MainForm : Form
|
||||
var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes)
|
||||
? $"RemSound {info.Tag} is available. Install now?"
|
||||
: $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?";
|
||||
var choice = MessageBox.Show(this, summary, "Update available",
|
||||
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
|
||||
var choice = ForegroundDialog.Show(owner => MessageBox.Show(owner, summary, "Update available",
|
||||
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1));
|
||||
if (choice == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -2310,7 +2330,7 @@ public sealed class MainForm : Form
|
||||
// "silent" install. UpdateInstallNoticeDialog auto-dismisses after a short
|
||||
// countdown but lets the user pick Install now / Skip / Postpone before then.
|
||||
using var notice = new UpdateInstallNoticeDialog(info);
|
||||
var choice = notice.ShowDialog(this);
|
||||
var choice = ForegroundDialog.Show(owner => notice.ShowDialog(owner));
|
||||
switch (choice)
|
||||
{
|
||||
case DialogResult.OK:
|
||||
@@ -2337,8 +2357,8 @@ public sealed class MainForm : Form
|
||||
var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes)
|
||||
? $"RemSound {info.Tag} is available. Install now?"
|
||||
: $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?";
|
||||
var pick = MessageBox.Show(this, summary, "Update available",
|
||||
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
|
||||
var pick = ForegroundDialog.Show(owner => MessageBox.Show(owner, summary, "Update available",
|
||||
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1));
|
||||
if (pick == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -2375,9 +2395,9 @@ public sealed class MainForm : Form
|
||||
// Nothing was staged — allow a later attempt rather than wedging the updater off
|
||||
// for the rest of the session.
|
||||
updateInstallStarted = false;
|
||||
MessageBox.Show(this,
|
||||
ForegroundDialog.Show(owner => MessageBox.Show(owner,
|
||||
$"Could not download or stage the update. Try again later, or visit the release page in your browser:\n\n{info.ReleaseUrl}",
|
||||
"Update failed", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
"Update failed", MessageBoxButtons.OK, MessageBoxIcon.Warning));
|
||||
return;
|
||||
}
|
||||
// The helper is staged and launched. We MUST now exit cleanly so it can replace our
|
||||
@@ -3754,6 +3774,7 @@ public sealed class MainForm : Form
|
||||
{
|
||||
ApplyAudioRuntime();
|
||||
}
|
||||
if (receiveOutputChanged) ReapplyRememberedReceiveOutputs();
|
||||
if (receiveOutputChanged || asioReceiveChanged)
|
||||
{
|
||||
ApplyReceiveDevices();
|
||||
@@ -3800,6 +3821,67 @@ 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>
|
||||
private static bool IsMicrophoneBlockedByWindowsPrivacy()
|
||||
{
|
||||
try
|
||||
{
|
||||
const string consent = @"Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone";
|
||||
return IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, consent)
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, consent + @"\NonPackaged")
|
||||
|| IsConsentDenied(Microsoft.Win32.Registry.LocalMachine, consent);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsConsentDenied(Microsoft.Win32.RegistryKey root, string subKey)
|
||||
{
|
||||
using var key = root.OpenSubKey(subKey);
|
||||
return string.Equals(key?.GetValue("Value") as string, "Deny", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private void WarnMicrophoneBlockedByWindowsPrivacy()
|
||||
{
|
||||
ForegroundDialog.Show(owner => MessageBox.Show(owner,
|
||||
"Windows is currently blocking desktop apps from using your microphone, so RemSound can "
|
||||
+ "switch the mic on but will only send silence - the people you're connected to won't "
|
||||
+ "hear you.\n\n"
|
||||
+ "To fix it, open Windows Settings, go to Privacy & security, then Microphone, and turn "
|
||||
+ "ON both of these:\n\n"
|
||||
+ " - Microphone access\n"
|
||||
+ " - Let desktop apps access your microphone\n\n"
|
||||
+ "Then your mic will work. This doesn't affect sound you receive - only sending your "
|
||||
+ "own microphone.",
|
||||
"Windows is blocking your microphone",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Warning));
|
||||
}
|
||||
|
||||
/// <summary>On profile load, if a WASAPI microphone is already ticked but Windows is blocking
|
||||
/// desktop-app mic access, show the warning once — the per-tick warning only fires on a fresh
|
||||
/// tick, so this covers a profile that loads with the mic already on. Same OK-only message.</summary>
|
||||
private void MaybeWarnMicBlockedOnStartup()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
if (!IsMicrophoneBlockedByWindowsPrivacy()) return;
|
||||
var anyWasapiMicChecked = false;
|
||||
for (var i = 0; i < sendInputDevicesList.Items.Count; i++)
|
||||
{
|
||||
if (sendInputDevicesList.GetItemChecked(i)) { anyWasapiMicChecked = true; break; }
|
||||
}
|
||||
if (anyWasapiMicChecked) WarnMicrophoneBlockedByWindowsPrivacy();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On startup, if a Realtek ASIO driver is installed and we haven't already disabled it or
|
||||
/// shown the warning, offer (once) to disable it — Realtek's ASIO driver leaks OS handles on
|
||||
@@ -3847,7 +3929,10 @@ public sealed class MainForm : Form
|
||||
+ "This driver is known to cause compatibility issues with ASIO software, including "
|
||||
+ "RemSound — it leaks system resources and can make audio unstable.\n\n"
|
||||
+ "Would you like to disable it in RemSound? RemSound will then never touch this "
|
||||
+ "driver. You can re-enable it any time from the Options menu.",
|
||||
+ "driver.\n\n"
|
||||
+ "Whichever you choose now, you can change it at any time: the Options menu has an "
|
||||
+ "\"Enable / Disable Realtek ASIO driver\" item that turns this driver on or off for "
|
||||
+ "RemSound whenever you like.",
|
||||
Icon = TaskDialogIcon.Warning,
|
||||
};
|
||||
var yes = new TaskDialogButton("&Yes, disable it (recommended)");
|
||||
@@ -3855,7 +3940,7 @@ public sealed class MainForm : Form
|
||||
page.Buttons.Add(yes);
|
||||
page.Buttons.Add(no);
|
||||
page.DefaultButton = yes;
|
||||
return TaskDialog.ShowDialog(this, page) == yes;
|
||||
return ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)) == yes;
|
||||
}
|
||||
|
||||
/// <summary>Options-menu handler: flip every installed Realtek ASIO driver between disabled and
|
||||
@@ -4058,6 +4143,32 @@ public sealed class MainForm : Form
|
||||
receiver.SetOutputDevices(ids);
|
||||
}
|
||||
|
||||
/// <summary>A receive-output card that was unplugged drops out of the WASAPI list (and its tick
|
||||
/// with it). When it returns, re-tick it from <see cref="rememberedReceiveOutputIds"/> so audio
|
||||
/// resumes automatically (issue #5). Only RE-ticks present-but-unticked remembered devices; it
|
||||
/// never unticks — that's a deliberate user action handled in the ItemCheck handler. Receive-only
|
||||
/// on purpose: the send lists keep their "re-tick each session" behaviour (see AudioDeviceCatalog).</summary>
|
||||
private void ReapplyRememberedReceiveOutputs()
|
||||
{
|
||||
if (rememberedReceiveOutputIds.Count == 0) return;
|
||||
var changed = false;
|
||||
suppressDeviceCheckChange = true;
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < receiveOutputDevicesList.Items.Count; i++)
|
||||
{
|
||||
if (receiveOutputDevicesList.Items[i] is not AudioDeviceChoice c || c.DeviceId is null) continue;
|
||||
if (rememberedReceiveOutputIds.Contains(c.DeviceId) && !receiveOutputDevicesList.GetItemChecked(i))
|
||||
{
|
||||
receiveOutputDevicesList.SetItemChecked(i, true);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { suppressDeviceCheckChange = false; }
|
||||
if (changed) logFile.Event("receive output: re-ticked a returning device from the remembered selection");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the audio-backend mode derived from the current ASIO driver choice. Two effective
|
||||
/// modes after the 2026-05-11 cleanup:
|
||||
@@ -4839,19 +4950,6 @@ public sealed class MainForm : Form
|
||||
if (!list.Focused) list.Focus();
|
||||
}
|
||||
|
||||
private void RemoveSelectedManualPeer(CheckedListBox list)
|
||||
{
|
||||
if (list.SelectedItem is not PeerListItem selected) return;
|
||||
manualPeers.Remove(selected.Peer.InstanceId);
|
||||
DeselectPeer(selected.Peer.InstanceId);
|
||||
foreach (var pair in rememberedPeerInstanceIds.Where(kv => kv.Value == selected.Peer.InstanceId).ToList())
|
||||
{
|
||||
rememberedPeerInstanceIds.Remove(pair.Key);
|
||||
}
|
||||
RefreshKnownPeers();
|
||||
ApplyAudioRuntime();
|
||||
}
|
||||
|
||||
private void RemoveSelectedRememberedPeer(CheckedListBox list)
|
||||
{
|
||||
if (list.SelectedItem is not RememberedPeerItem selected) return;
|
||||
@@ -5494,11 +5592,20 @@ public sealed class MainForm : Form
|
||||
{
|
||||
// Volume first — affects what's audible during the rest of this method.
|
||||
volumeBar.Value = Math.Clamp(p.Volume, volumeBar.Minimum, volumeBar.Maximum);
|
||||
// Push volume + mute to the engine. Assigning .Value does NOT fire the Scroll handler, so
|
||||
// without this a profile saved at e.g. 50% would show 50% but play at full volume until
|
||||
// the slider was nudged. Mute is restored from the saved state for the same reason.
|
||||
receiver.Volume = volumeBar.Value / 100f;
|
||||
receiver.IsMuted = p.Muted;
|
||||
|
||||
// Tick checkboxes. Order matters: setting Checked fires runtime apply paths
|
||||
// (Connect/Disconnect) so the side-effect cascade has to happen here, not in
|
||||
// the constructor where the engines aren't fully wired up yet.
|
||||
ApplyTicksToList(receiveOutputDevicesList, p.SelectedWasapiReceiveOutputs);
|
||||
// Seed remembered receive-output intent from the profile so a selected card that's
|
||||
// absent now (or unplugged later) is re-ticked + re-opened when it appears (issue #5).
|
||||
rememberedReceiveOutputIds.Clear();
|
||||
foreach (var rid in p.SelectedWasapiReceiveOutputs) rememberedReceiveOutputIds.Add(rid);
|
||||
ApplyTicksToList(asioReceiveOutputDevicesList, p.SelectedAsioReceiveOutputs);
|
||||
ApplyTicksToList(sendOutputDevicesList, p.SelectedWasapiSendOutputs);
|
||||
ApplyTicksToList(sendInputDevicesList, p.SelectedWasapiSendInputs);
|
||||
@@ -5524,9 +5631,6 @@ public sealed class MainForm : Form
|
||||
pendingProfile = null;
|
||||
applyingProfile = false;
|
||||
}
|
||||
// Schedule baseline capture for the unsaved-changes-on-close check. Done as a
|
||||
// delayed snapshot so async peer-reconnects have settled.
|
||||
ScheduleBaselineCapture();
|
||||
}
|
||||
|
||||
/// <summary>Tick the items in <paramref name="list"/> whose DeviceId appears in
|
||||
@@ -5561,21 +5665,6 @@ public sealed class MainForm : Form
|
||||
: $"{AppName} — Active profile: {loadedTitle}{readOnlySuffix}";
|
||||
}
|
||||
|
||||
/// <summary>Show/hide the Update button based on whether a profile is currently loaded.
|
||||
/// Update only makes sense when there's an existing profile to overwrite; Save-as is
|
||||
/// always available (and the only way to save from a blank template). Both Visible and
|
||||
/// Enabled are toggled — Visible to keep NVDA / sighted users from seeing it, Enabled
|
||||
/// so the Alt+U hotkey is a no-op even if focus somehow lands on it.</summary>
|
||||
private void UpdateProfileButtonsVisibility()
|
||||
{
|
||||
// Retained as a stub — multiple call sites still poke this on profile load /
|
||||
// save-as / rename. With the Profiles tab retired (2026-05-08) there's no UI to
|
||||
// refresh; the Save / Rename actions on the File menu work for both
|
||||
// blank-template and loaded-profile states because the menu handlers branch on
|
||||
// currentProfileTitle internally. The window title is updated where the profile
|
||||
// title actually changes (SaveProfileTo, RenameCurrentProfile, profile-load).
|
||||
}
|
||||
|
||||
/// <summary>Update existing profile button. Overwrites the active profile with current
|
||||
/// state. No prompt — user explicitly chose this button to commit. Hidden when no
|
||||
/// profile is loaded.</summary>
|
||||
@@ -5647,11 +5736,7 @@ public sealed class MainForm : Form
|
||||
}
|
||||
Text = FormatWindowTitle(title);
|
||||
AccessibleName = Text;
|
||||
UpdateProfileButtonsVisibility();
|
||||
AppendLogEntry($"profile saved: \"{title}\" → {path}");
|
||||
// Refresh baseline so the diff against unsaved-changes uses the just-saved state.
|
||||
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
|
||||
catch { /* baseline failure shouldn't block save */ }
|
||||
unsavedChanges = false;
|
||||
// A freshly created profile has no password yet, and encryption is always on — so
|
||||
// ask for one now and write it straight into the file we just saved. OK requires a
|
||||
@@ -5659,7 +5744,7 @@ public sealed class MainForm : Form
|
||||
// streaming gate will ask again when needed.
|
||||
if (string.IsNullOrEmpty(currentProfilePassword))
|
||||
{
|
||||
var pw = ProfilePasswordDialog.Show(this, title, "", requireNonEmpty: true);
|
||||
var pw = ProfilePasswordDialog.Show(title, "", requireNonEmpty: true);
|
||||
if (!string.IsNullOrEmpty(pw))
|
||||
{
|
||||
currentProfilePassword = pw;
|
||||
@@ -5728,11 +5813,6 @@ public sealed class MainForm : Form
|
||||
// profile flag; the cue is silent if the user has unticked it in Preferences or if
|
||||
// sounds\save.wav doesn't exist and no custom override has been set.
|
||||
if (settings.LoadEnableSaveCue()) saveSound?.Play();
|
||||
// Refresh the unsaved-changes baseline so this saved state becomes the new
|
||||
// "no changes" reference. The Title field changes on save-as, so the next
|
||||
// diff comparison must use the new state as baseline, not the pre-save one.
|
||||
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
|
||||
catch { /* baseline failure shouldn't block save */ }
|
||||
unsavedChanges = false;
|
||||
if (showConfirmation && !AppConfig.Load().SaveProfileConfirmationSuppressed)
|
||||
{
|
||||
@@ -5778,7 +5858,6 @@ public sealed class MainForm : Form
|
||||
currentProfileTitle = title;
|
||||
Text = FormatWindowTitle(title);
|
||||
AccessibleName = Text;
|
||||
UpdateProfileButtonsVisibility();
|
||||
}
|
||||
|
||||
/// <summary>Mark the profile as having unsaved user changes. No-op while a profile is
|
||||
@@ -5829,16 +5908,6 @@ public sealed class MainForm : Form
|
||||
profile.ReadOnly = readOnly;
|
||||
var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(currentProfilePath, newJson);
|
||||
// Refresh the unsaved-changes baseline so any user edits made BEFORE the toggle
|
||||
// remain "unsaved" (still pending a real Save) — the baseline tracks the saved
|
||||
// profile JSON, and we just rewrote it on disk, so the diff has to be against
|
||||
// the new file contents not the old ones. Without this, toggling lock on a
|
||||
// dirty profile would suddenly "clean" the dirty flag from the close path's
|
||||
// POV, even though the user's other edits still aren't persisted. The new
|
||||
// baseline reflects the on-disk truth; the in-memory state still differs by
|
||||
// those other edits, so unsavedChanges-style tracking still works.
|
||||
try { baselineProfileJson = SerializeProfileForDirtyDiff(profile); }
|
||||
catch { /* baseline refresh is best-effort */ }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -5851,14 +5920,6 @@ public sealed class MainForm : Form
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serialise an arbitrary <see cref="Profile"/> in the same shape
|
||||
/// <see cref="SerializeCurrentStateAsProfile"/> uses for the dirty-diff. Lives here so
|
||||
/// the lock-flag persistence path can refresh the baseline against the rewritten file
|
||||
/// contents (a partial overwrite of the profile file) without flushing the user's
|
||||
/// in-session edits. 2026-05-22.</summary>
|
||||
private static string SerializeProfileForDirtyDiff(Profile profile) =>
|
||||
JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
/// <summary>File → Change this profile's password. Shows the current password (plain text,
|
||||
/// for the screen reader) in a dialog; on OK, updates the in-memory value and writes JUST
|
||||
/// the password back to the profile file straight away — same immediate-persist approach as
|
||||
@@ -5874,7 +5935,7 @@ public sealed class MainForm : Form
|
||||
AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
var entered = ProfilePasswordDialog.Show(this, currentProfileTitle, currentProfilePassword);
|
||||
var entered = ProfilePasswordDialog.Show(currentProfileTitle, currentProfilePassword);
|
||||
if (entered is null) return; // cancelled
|
||||
currentProfilePassword = entered;
|
||||
RecomputeAudioCrypto();
|
||||
@@ -5898,10 +5959,6 @@ public sealed class MainForm : Form
|
||||
profile.Password = RemSoundCrypto.Obfuscate(plaintextPassword);
|
||||
var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(currentProfilePath, newJson);
|
||||
// Refresh the dirty-diff baseline against the rewritten file so the password change
|
||||
// we just persisted doesn't read back as an unsaved change on close.
|
||||
try { baselineProfileJson = SerializeProfileForDirtyDiff(profile); }
|
||||
catch { /* baseline refresh is best-effort */ }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -5946,7 +6003,7 @@ public sealed class MainForm : Form
|
||||
if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one
|
||||
|
||||
var label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle;
|
||||
var entered = ProfilePasswordDialog.Show(this, label, "", requireNonEmpty: true);
|
||||
var entered = ProfilePasswordDialog.Show(label, "", requireNonEmpty: true);
|
||||
if (string.IsNullOrEmpty(entered))
|
||||
{
|
||||
// No password → can't stream. Put the box back without re-firing this gate.
|
||||
@@ -5960,9 +6017,9 @@ public sealed class MainForm : Form
|
||||
// Offer to remember it on the profile (if we're on a saved one).
|
||||
if (!string.IsNullOrEmpty(currentProfileTitle) && !string.IsNullOrEmpty(currentProfilePath))
|
||||
{
|
||||
var save = MessageBox.Show(this,
|
||||
var save = ForegroundDialog.Show(owner => MessageBox.Show(owner,
|
||||
$"Save this password to profile \"{currentProfileTitle}\" so you don't have to type it next time?",
|
||||
AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question);
|
||||
AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question));
|
||||
if (save == DialogResult.Yes) PersistPasswordOnly(currentProfilePassword);
|
||||
}
|
||||
return true;
|
||||
@@ -6032,31 +6089,6 @@ public sealed class MainForm : Form
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current control state as if the user had just clicked Save.
|
||||
/// Used for the unsaved-changes-on-close diff. Mirrors <see cref="SaveCurrentStateToProfileFile"/>
|
||||
/// but doesn't write anywhere.</summary>
|
||||
private string SerializeCurrentStateAsProfile() =>
|
||||
JsonSerializer.Serialize(BuildCurrentProfile(currentProfileTitle ?? ""));
|
||||
|
||||
/// <summary>Capture the "this is what no-changes-since-load looks like" baseline 3 seconds
|
||||
/// after the profile has been applied (or the app has started, for blank template). The
|
||||
/// delay lets async peer-reconnects finish so they're folded into the baseline rather
|
||||
/// than seen as user-initiated changes. If the user closes within those 3 seconds the
|
||||
/// baseline is null and we just close without prompting (treating fast-close as
|
||||
/// confident-close).</summary>
|
||||
private void ScheduleBaselineCapture()
|
||||
{
|
||||
var timer = new System.Windows.Forms.Timer { Interval = 3000 };
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
timer.Stop();
|
||||
timer.Dispose();
|
||||
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
|
||||
catch { /* ignore — baseline just stays null */ }
|
||||
};
|
||||
timer.Start();
|
||||
}
|
||||
|
||||
private static List<string> ExtractCheckedDeviceIds(CheckedListBox list)
|
||||
{
|
||||
var result = new List<string>();
|
||||
@@ -6297,7 +6329,7 @@ public sealed class MainForm : Form
|
||||
}
|
||||
else
|
||||
{
|
||||
var defaultPath = Path.Combine(AppContext.BaseDirectory, "sounds", defaultFileName);
|
||||
var defaultPath = Path.Combine(AppConfig.SoundsDirectory, defaultFileName);
|
||||
if (File.Exists(defaultPath))
|
||||
{
|
||||
path = defaultPath;
|
||||
@@ -6794,11 +6826,6 @@ public sealed class MainForm : Form
|
||||
// can't make the ASIO lane's auto-tune defer (and vice versa).
|
||||
if (settings.LoadAudioMode() == AudioMode.BothIndependent)
|
||||
{
|
||||
// Skip ticking a lane that has no active sessions. The shared recentMaxGaps
|
||||
// window is populated by every incoming packet regardless of lane, so without
|
||||
// this gate a route with no audio would still react to the OTHER route's
|
||||
// gap signal and silently inflate its target before any of its own audio has
|
||||
// arrived.
|
||||
// Skip ticking a lane that has no active sessions. The shared recentMaxGaps
|
||||
// window is populated by every incoming packet regardless of lane, so without
|
||||
// this gate a route with no audio would still react to the OTHER route's
|
||||
|
||||
@@ -622,7 +622,7 @@ internal sealed class PreferencesDialog : Form
|
||||
_ => null,
|
||||
};
|
||||
if (defaultFileName is null) return null;
|
||||
var defaultPath = Path.Combine(AppContext.BaseDirectory, "sounds", defaultFileName);
|
||||
var defaultPath = Path.Combine(AppConfig.SoundsDirectory, defaultFileName);
|
||||
return File.Exists(defaultPath) ? defaultPath : null;
|
||||
}
|
||||
|
||||
@@ -667,7 +667,7 @@ internal sealed class PreferencesDialog : Form
|
||||
/// fires on the way out.</summary>
|
||||
private void OnBrowseClicked(Button btn, CueRowDescriptor cue, RemSoundSettingsStore settings)
|
||||
{
|
||||
var soundsFolder = Path.Combine(AppContext.BaseDirectory, "sounds");
|
||||
var soundsFolder = AppConfig.SoundsDirectory;
|
||||
var existing = settings.LoadCustomCuePath(cue.CueId);
|
||||
var initialDir = !string.IsNullOrWhiteSpace(existing) && File.Exists(existing)
|
||||
? Path.GetDirectoryName(existing) ?? soundsFolder
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace RemSound.App;
|
||||
/// </summary>
|
||||
internal static class ProfilePasswordDialog
|
||||
{
|
||||
public static string? Show(IWin32Window owner, string profileTitle, string currentPassword, bool requireNonEmpty = false)
|
||||
public static string? Show(string profileTitle, string currentPassword, bool requireNonEmpty = false)
|
||||
{
|
||||
using var dialog = new Form
|
||||
{
|
||||
@@ -99,6 +99,12 @@ internal static class ProfilePasswordDialog
|
||||
dialog.AcceptButton = okButton;
|
||||
dialog.CancelButton = cancelButton;
|
||||
|
||||
return dialog.ShowDialog(owner) == DialogResult.OK ? textBox.Text.Trim() : null;
|
||||
// Run with a foreground 1×1 owner so the prompt jumps to the front even when RemSound is
|
||||
// sitting minimised in the tray — e.g. a quick profile switch to a passwordless-but-
|
||||
// streaming profile trips the password gate mid-switch, and the user must be able to read
|
||||
// and answer it there and then. Centres on screen, forces focus, then closes.
|
||||
return ForegroundDialog.Show(owner => dialog.ShowDialog(owner)) == DialogResult.OK
|
||||
? textBox.Text.Trim()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
+92
-17
@@ -24,10 +24,19 @@ internal static class Program
|
||||
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
// Relocate any pre-2026-06-07 config/profiles into config\ before anything reads them.
|
||||
// Idempotent and best-effort; also upgrades users coming from an older build. The result
|
||||
// is shown to the user once (after the single-instance guard) if files actually moved.
|
||||
// Consolidate every older layout (loose files, or the interim config\ folder) into the single
|
||||
// "user settings and logs" folder before anything reads config/profiles/logs. Idempotent +
|
||||
// best-effort; upgrades users from any older build. Shown to the user once if files moved.
|
||||
var layoutMigration = RemSound.Core.AppConfig.MigrateLegacyLayoutIfNeeded();
|
||||
// Move the cue sounds into that folder too — seeded from the shipped defaults (see method).
|
||||
ConsolidateSounds();
|
||||
|
||||
// Remove cue WAVs (and their .sfk peak files) left loose in the install ROOT by pre-
|
||||
// 2026-05-28 builds, where the cues lived next to RemSound.exe before they moved into
|
||||
// sounds\. A robocopy update copies the new sounds\ tree but uses /E (not /PURGE), so it
|
||||
// never deletes these orphans — they just linger in the root. Best-effort + idempotent:
|
||||
// a no-op once they're gone. 2026-06-08.
|
||||
CleanUpLegacyRootSounds();
|
||||
|
||||
// Single-instance guard. RemSound must never run as two copies at once: with the
|
||||
// auto-updater relaunching the app, a copy that didn't exit cleanly used to leave two
|
||||
@@ -84,7 +93,7 @@ internal static class Program
|
||||
// here, after the guard and before the profile picker, so the user reads it once up front.
|
||||
if (layoutMigration.MovedAnything)
|
||||
{
|
||||
ShowLayoutMigrationNotice(layoutMigration);
|
||||
ShowLayoutMigrationNotice();
|
||||
}
|
||||
|
||||
// Outer loop: lets ProfileManagementDialog change the profiles folder mid-session.
|
||||
@@ -248,28 +257,94 @@ internal static class Program
|
||||
}
|
||||
|
||||
/// <summary>One-time, Windows-native notice telling the user their config/profiles were moved
|
||||
/// into the new <c>config\</c> folder. Only called when a real migration happened. TaskDialog
|
||||
/// into the new "user settings and logs" folder. Only called when a real migration happened. TaskDialog
|
||||
/// (not a hand-rolled Form) so a screen reader reads the whole message automatically.</summary>
|
||||
private static void ShowLayoutMigrationNotice(RemSound.Core.AppConfig.LayoutMigrationResult migration)
|
||||
private static void ShowLayoutMigrationNotice()
|
||||
{
|
||||
var moved = new System.Collections.Generic.List<string>();
|
||||
if (migration.MovedGlobalConfig) moved.Add("- Your settings are now in: config\\global config.json");
|
||||
if (migration.MovedProfiles) moved.Add("- Your saved profiles are now in: config\\profiles\\");
|
||||
|
||||
var page = new TaskDialogPage
|
||||
{
|
||||
Caption = "RemSound settings location",
|
||||
Heading = "Your settings now live in a \"config\" folder",
|
||||
Text = "To keep the RemSound folder tidy, this update moved your existing settings into a new "
|
||||
+ "\"config\" folder inside RemSound:\n\n"
|
||||
+ string.Join("\n", moved)
|
||||
+ "\n\nNothing was lost and RemSound works exactly as before. You will only see this message once.",
|
||||
Caption = "RemSound files location",
|
||||
Heading = "Your RemSound files have moved into one folder",
|
||||
Text = "To keep the RemSound folder tidy and stop updates from ever touching your own files, "
|
||||
+ "this update moved everything this machine owns into a single folder inside RemSound "
|
||||
+ "called \"user settings and logs\":\n\n"
|
||||
+ "- Your settings (global config)\n"
|
||||
+ "- Your saved profiles\n"
|
||||
+ "- Your logs\n"
|
||||
+ "- Your cue sounds\n\n"
|
||||
+ "Nothing was lost and RemSound works exactly as before. From now on, RemSound updates "
|
||||
+ "leave that folder completely untouched. You will only see this message once.",
|
||||
Icon = TaskDialogIcon.Information,
|
||||
Buttons = { TaskDialogButton.OK },
|
||||
DefaultButton = TaskDialogButton.OK,
|
||||
AllowCancel = true,
|
||||
};
|
||||
try { TaskDialog.ShowDialog(page); }
|
||||
// Give the notice a momentary top-most, foreground owner so it opens FRONT and CENTRE —
|
||||
// RemSound may have launched straight into the tray (auto-start / start-minimised), and a
|
||||
// parent-less TaskDialog can otherwise open behind everything where a screen-reader user
|
||||
// can't read it.
|
||||
try { ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)); }
|
||||
catch { /* a notice must never stop RemSound from starting */ }
|
||||
}
|
||||
|
||||
/// <summary>Delete cue WAVs and their .sfk peak files left loose in the install ROOT by
|
||||
/// pre-2026-05-28 builds (the cues moved into <c>sounds\</c> then; a robocopy update copies
|
||||
/// the new tree but never removes the old root copies). Best-effort and idempotent — runs
|
||||
/// every launch and no-ops once the orphans are gone. Only the known default cue names are
|
||||
/// touched, never anything else in the folder.</summary>
|
||||
private static void CleanUpLegacyRootSounds()
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = AppContext.BaseDirectory;
|
||||
string[] cueBaseNames =
|
||||
{
|
||||
"connect", "disconnect", "record start", "record stop",
|
||||
"save", "profile", "profile menu open", "update",
|
||||
};
|
||||
foreach (var baseName in cueBaseNames)
|
||||
{
|
||||
foreach (var fileName in new[] { baseName + ".wav", baseName + ".sfk", baseName + ".wav.sfk" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(root, fileName);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
catch { /* a locked / unremovable file must never stop startup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* never let cleanup disturb startup */ }
|
||||
}
|
||||
|
||||
/// <summary>Consolidate the cue WAVs into the per-user sounds folder. The release ships the
|
||||
/// default cues in <c><exe>\sounds\</c>; this copies any cue MISSING from the per-user
|
||||
/// <c>...\user settings and logs\sounds\</c> across (so a fresh install, or a release that adds a
|
||||
/// new cue, gets seeded) WITHOUT overwriting one already there (so the user's own cue files
|
||||
/// survive), then removes the shipped folder to keep the install root tidy. The app reads cues
|
||||
/// only from the per-user folder, which the updater leaves untouched — so a user's custom cue
|
||||
/// WAVs are no longer clobbered by an update. Best-effort + idempotent. 2026-06-10.</summary>
|
||||
private static void ConsolidateSounds()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userSounds = AppConfig.SoundsDirectory;
|
||||
Directory.CreateDirectory(userSounds);
|
||||
var shippedSounds = Path.Combine(AppContext.BaseDirectory, "sounds");
|
||||
if (!Directory.Exists(shippedSounds)) return;
|
||||
foreach (var src in Directory.GetFiles(shippedSounds))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dest = Path.Combine(userSounds, Path.GetFileName(src));
|
||||
if (!File.Exists(dest)) File.Copy(src, dest);
|
||||
}
|
||||
catch { /* one unreadable cue mustn't stop the rest */ }
|
||||
}
|
||||
try { Directory.Delete(shippedSounds, recursive: true); }
|
||||
catch { /* leave it if locked — the app reads the per-user copy anyway */ }
|
||||
}
|
||||
catch { /* never let cue consolidation disturb startup */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.4.0</Version>
|
||||
<Version>3.5.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -68,7 +68,7 @@ internal sealed class RemSoundLog : IDisposable
|
||||
if (fileCreationFailed) return false;
|
||||
try
|
||||
{
|
||||
var dir = System.IO.Path.Combine(AppContext.BaseDirectory, "logs");
|
||||
var dir = RemSound.Core.AppConfig.LogsDirectory;
|
||||
Directory.CreateDirectory(dir);
|
||||
var name = $"RemSound-{Sanitize(Environment.MachineName)}-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}.log";
|
||||
Path = System.IO.Path.Combine(dir, name);
|
||||
|
||||
@@ -317,6 +317,7 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
|
||||
echo. >> "%LOG%"
|
||||
echo === %DATE% %TIME% update helper started, parent PID=%PID% === >> "%LOG%"
|
||||
echo %DATE% %TIME% install dir=[%~dp0] >> "%LOG%"
|
||||
|
||||
:wait_loop
|
||||
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
|
||||
@@ -326,13 +327,18 @@ internal sealed class RemSoundUpdater : IDisposable
|
||||
)
|
||||
|
||||
echo %DATE% %TIME% parent exited, starting robocopy (R:60 W:1) >> "%LOG%"
|
||||
rem /XF + /XD keep the update from ever overwriting the USER's own state: their
|
||||
rem machine-local config (remsound.config.json — holds the profiles-folder choice and
|
||||
rem startup settings) and their data folders (logs / profiles / recordings). An update
|
||||
rem replaces APP files only. build-release.ps1 already keeps those out of the release
|
||||
rem zip; this is the second line of defence so a bad zip still can't clobber them.
|
||||
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XF {ResumeProfileSentinelName} /XD logs profiles recordings _update /LOG+:"%LOG%"
|
||||
rem /XF + /XD keep the update from ever overwriting the USER's own state: everything under
|
||||
rem "user settings and logs" (global config, profiles, logs, sounds — including any custom cue
|
||||
rem WAVs the user dropped in) plus the legacy loose config. An update replaces APP files only.
|
||||
rem build-release.ps1 keeps those out of the release zip; this is the second line of defence so
|
||||
rem a bad zip still can't clobber them. The bare logs/profiles/recordings excludes stay for any
|
||||
rem older layout still mid-migration.
|
||||
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XF {ResumeProfileSentinelName} /XD logs profiles recordings _update "user settings and logs" /LOG+:"%LOG%"
|
||||
set "ROBO_EXIT=%ERRORLEVEL%"
|
||||
rem Guard against an empty exit code (e.g. robocopy never ran / ERRORLEVEL was clobbered):
|
||||
rem an empty %ROBO_EXIT% turns the GEQ test below into a parse error. Default it to a
|
||||
rem clear non-zero so the failure path is taken cleanly and logged with a real number.
|
||||
if not defined ROBO_EXIT set "ROBO_EXIT=99"
|
||||
echo %DATE% %TIME% robocopy exit=%ROBO_EXIT% >> "%LOG%"
|
||||
|
||||
if %ROBO_EXIT% GEQ 8 (
|
||||
|
||||
@@ -130,6 +130,11 @@ internal sealed class RouterPortMapper : IDisposable
|
||||
RaiseChanged();
|
||||
try
|
||||
{
|
||||
// Subscribe-once: Refresh() (fired on every sleep/wake) calls Start() again without a
|
||||
// prior unsubscribe, and DeviceFound is a STATIC Mono.Nat event — a bare += would stack
|
||||
// a new handler every resume (duplicate port-maps + this object kept alive forever).
|
||||
// Remove first so there's only ever one subscription.
|
||||
NatUtility.DeviceFound -= OnDeviceFound;
|
||||
NatUtility.DeviceFound += OnDeviceFound;
|
||||
NatUtility.StartDiscovery();
|
||||
log?.Invoke("UPnP discovery started");
|
||||
|
||||
@@ -162,8 +162,10 @@ internal sealed class StartupBehaviourDialog : Form
|
||||
try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save Start minimised preference: " + ex.Message); }
|
||||
};
|
||||
|
||||
var suppressStartWithUserHandler = false;
|
||||
startWithUserBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
if (suppressStartWithUserHandler) return;
|
||||
// Source of truth for the auto-start state is the registry — we don't keep a
|
||||
// duplicate in AppConfig. So this just flips the registry entry directly.
|
||||
var ok = startWithUserBox.Checked
|
||||
@@ -176,21 +178,18 @@ internal sealed class StartupBehaviourDialog : Form
|
||||
"Auto-start change failed",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
// Re-read truth and reflect it without re-firing this handler.
|
||||
// Re-read truth and reflect it WITHOUT re-firing this handler. The suppress flag
|
||||
// genuinely gates it; the old detach/re-attach targeted an empty handler that was
|
||||
// never in the invocation list, so it did nothing and the corrective set re-fired.
|
||||
var actual = StartupAutoStart.IsEnabled;
|
||||
if (startWithUserBox.Checked != actual)
|
||||
{
|
||||
// Temporarily detach the handler to avoid a recursive call.
|
||||
var savedChecked = actual;
|
||||
startWithUserBox.CheckedChanged -= AutoStartReentryGuard;
|
||||
startWithUserBox.Checked = savedChecked;
|
||||
startWithUserBox.CheckedChanged += AutoStartReentryGuard;
|
||||
suppressStartWithUserHandler = true;
|
||||
try { startWithUserBox.Checked = actual; }
|
||||
finally { suppressStartWithUserHandler = false; }
|
||||
}
|
||||
}
|
||||
};
|
||||
// Empty handler used as a target-for-removal in the re-entry-guard path above.
|
||||
// Kept so the +=/-= pair is symmetrical even though it does nothing on its own.
|
||||
void AutoStartReentryGuard(object? _, EventArgs __) { }
|
||||
|
||||
startWithProfileBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
|
||||
+103
-35
@@ -206,57 +206,112 @@ public sealed class AppConfig
|
||||
!string.IsNullOrWhiteSpace(driverName)
|
||||
&& driverName.Contains("Realtek", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>The config folder next to the exe (<c><exe>\config\</c>). Holds the global
|
||||
/// config file and the <c>profiles\</c> subfolder. 2026-06-07: everything non-recording config
|
||||
/// moved in here from loose files beside the exe, so the install root stays tidy.</summary>
|
||||
public static string ConfigDirectory => Path.Combine(AppContext.BaseDirectory, "config");
|
||||
/// <summary>The single per-user folder next to the exe — <c><exe>\user settings and logs\</c> —
|
||||
/// that holds EVERYTHING this machine's user owns: the global config file, the <c>profiles\</c>
|
||||
/// subfolder, <c>logs\</c>, and <c>sounds\</c>. 2026-06-10: consolidated here from the loose files
|
||||
/// / the earlier <c>config\</c> folder so the install root stays tidy and the auto-updater can
|
||||
/// exclude one folder to leave ALL user state (including custom cue WAVs) untouched.</summary>
|
||||
public const string UserDataFolderName = "user settings and logs";
|
||||
public static string UserDataDirectory => Path.Combine(AppContext.BaseDirectory, UserDataFolderName);
|
||||
|
||||
private static string ConfigPath => Path.Combine(ConfigDirectory, "global config.json");
|
||||
/// <summary>Where the per-machine log files are written.</summary>
|
||||
public static string LogsDirectory => Path.Combine(UserDataDirectory, "logs");
|
||||
|
||||
/// <summary>Where the cue WAVs live (seeded from the shipped defaults; see Program.ConsolidateSounds).</summary>
|
||||
public static string SoundsDirectory => Path.Combine(UserDataDirectory, "sounds");
|
||||
|
||||
/// <summary>The base profiles folder (ProfileStore appends the per-machine subfolder).</summary>
|
||||
public static string ProfilesBaseDirectory => Path.Combine(UserDataDirectory, "profiles");
|
||||
|
||||
private static string ConfigPath => Path.Combine(UserDataDirectory, "global config.json");
|
||||
|
||||
/// <summary>What <see cref="MigrateLegacyLayoutIfNeeded"/> relocated this launch. True only on the
|
||||
/// one launch where an older layout was found and moved — the caller uses it to show a one-time
|
||||
/// "everything moved" notice.</summary>
|
||||
public readonly record struct LayoutMigrationResult(bool MovedAnything);
|
||||
|
||||
/// <summary>
|
||||
/// One-time, idempotent relocation of the pre-2026-06-07 layout into <c>config\</c>:
|
||||
/// * <c><exe>\remsound.config.json</c> → <c><exe>\config\global config.json</c>
|
||||
/// * <c><exe>\profiles\</c> → <c><exe>\config\profiles\</c>
|
||||
/// Run once at startup BEFORE anything reads config or profiles. Each move only happens when
|
||||
/// the old item exists and the new one doesn't, so it's safe to call every launch and it
|
||||
/// upgrades anyone coming from an older build without losing a profile or a setting. A custom
|
||||
/// <see cref="ProfilesDirectory"/> is untouched — it isn't in the default location.
|
||||
/// One-time, idempotent consolidation of EVERY older layout into
|
||||
/// <c><exe>\user settings and logs\</c>. Handles all the field permutations, each move guarded
|
||||
/// by "source exists AND destination doesn't" so it's safe to run every launch and never clobbers
|
||||
/// already-migrated data:
|
||||
/// * global config: <c><exe>\remsound.config.json</c> (oldest) OR
|
||||
/// <c><exe>\config\global config.json</c> (the 2026-06-07 interim layout)
|
||||
/// * profiles: <c><exe>\config\profiles\</c> (interim) OR <c><exe>\profiles\</c> (oldest)
|
||||
/// * logs: <c><exe>\logs\</c>
|
||||
/// → all under <c>...\user settings and logs\</c>. (Sounds are consolidated separately by
|
||||
/// Program.ConsolidateSounds — the shipped default cues need seeding, not a plain move.) Runs
|
||||
/// BEFORE anything reads config/profiles/logs. A custom <see cref="ProfilesDirectory"/> is
|
||||
/// untouched. Directory moves fall back to copy-then-delete across a volume boundary.
|
||||
/// </summary>
|
||||
/// <summary>What <see cref="MigrateLegacyLayoutIfNeeded"/> actually relocated this launch.
|
||||
/// <see cref="MovedAnything"/> is true only on the one launch where an upgrade's old files
|
||||
/// were found and moved — the caller uses it to show a one-time "your settings moved" notice.</summary>
|
||||
public readonly record struct LayoutMigrationResult(bool MovedGlobalConfig, bool MovedProfiles)
|
||||
{
|
||||
public bool MovedAnything => MovedGlobalConfig || MovedProfiles;
|
||||
}
|
||||
|
||||
public static LayoutMigrationResult MigrateLegacyLayoutIfNeeded()
|
||||
{
|
||||
var movedGlobal = false;
|
||||
var movedProfiles = false;
|
||||
var moved = false;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ConfigDirectory);
|
||||
var oldGlobal = Path.Combine(AppContext.BaseDirectory, "remsound.config.json");
|
||||
if (File.Exists(oldGlobal) && !File.Exists(ConfigPath))
|
||||
Directory.CreateDirectory(UserDataDirectory);
|
||||
var root = AppContext.BaseDirectory;
|
||||
var interimConfigDir = Path.Combine(root, "config");
|
||||
|
||||
// Global config — interim location wins over the oldest loose file.
|
||||
if (!File.Exists(ConfigPath))
|
||||
{
|
||||
File.Move(oldGlobal, ConfigPath);
|
||||
movedGlobal = true;
|
||||
var interimGlobal = Path.Combine(interimConfigDir, "global config.json");
|
||||
var oldestGlobal = Path.Combine(root, "remsound.config.json");
|
||||
if (File.Exists(interimGlobal)) { File.Move(interimGlobal, ConfigPath); moved = true; }
|
||||
else if (File.Exists(oldestGlobal)) { File.Move(oldestGlobal, ConfigPath); moved = true; }
|
||||
}
|
||||
var oldProfiles = Path.Combine(AppContext.BaseDirectory, "profiles");
|
||||
var newProfiles = Path.Combine(ConfigDirectory, "profiles");
|
||||
if (Directory.Exists(oldProfiles) && !Directory.Exists(newProfiles))
|
||||
|
||||
// Profiles — interim location wins over the oldest.
|
||||
if (!Directory.Exists(ProfilesBaseDirectory))
|
||||
{
|
||||
Directory.Move(oldProfiles, newProfiles);
|
||||
movedProfiles = true;
|
||||
var interimProfiles = Path.Combine(interimConfigDir, "profiles");
|
||||
var oldestProfiles = Path.Combine(root, "profiles");
|
||||
if (Directory.Exists(interimProfiles)) { MoveDirectoryResilient(interimProfiles, ProfilesBaseDirectory); moved = true; }
|
||||
else if (Directory.Exists(oldestProfiles)) { MoveDirectoryResilient(oldestProfiles, ProfilesBaseDirectory); moved = true; }
|
||||
}
|
||||
|
||||
// Logs (only ever lived loose in the root).
|
||||
var oldLogs = Path.Combine(root, "logs");
|
||||
if (Directory.Exists(oldLogs) && !Directory.Exists(LogsDirectory)) { MoveDirectoryResilient(oldLogs, LogsDirectory); moved = true; }
|
||||
|
||||
// Remove the now-empty 2026-06-07 interim config\ folder.
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(interimConfigDir) && Directory.GetFileSystemEntries(interimConfigDir).Length == 0)
|
||||
Directory.Delete(interimConfigDir);
|
||||
}
|
||||
catch { /* leave it if it isn't empty / can't be removed */ }
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: a failed move (permissions, file in use) just means the app falls
|
||||
// back to defaults / an empty profiles list rather than crashing on launch.
|
||||
}
|
||||
return new LayoutMigrationResult(movedGlobal, movedProfiles);
|
||||
return new LayoutMigrationResult(moved);
|
||||
}
|
||||
|
||||
/// <summary>Move a directory, falling back to recursive copy-then-delete when a plain
|
||||
/// <see cref="Directory.Move"/> can't cross a volume boundary (e.g. the user-data folder is a
|
||||
/// junction onto another drive). Copy uses overwrite:false so an already-present destination
|
||||
/// file is never clobbered.</summary>
|
||||
private static void MoveDirectoryResilient(string source, string dest)
|
||||
{
|
||||
try { Directory.Move(source, dest); }
|
||||
catch (IOException)
|
||||
{
|
||||
CopyDirectoryRecursive(source, dest);
|
||||
try { Directory.Delete(source, recursive: true); } catch { /* copy succeeded; leaving the source is harmless */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyDirectoryRecursive(string source, string dest)
|
||||
{
|
||||
Directory.CreateDirectory(dest);
|
||||
foreach (var file in Directory.GetFiles(source))
|
||||
File.Copy(file, Path.Combine(dest, Path.GetFileName(file)), overwrite: false);
|
||||
foreach (var dir in Directory.GetDirectories(source))
|
||||
CopyDirectoryRecursive(dir, Path.Combine(dest, Path.GetFileName(dir)));
|
||||
}
|
||||
|
||||
/// <summary>Reads the app config from disk. Always returns a non-null instance — a missing
|
||||
@@ -282,9 +337,22 @@ public sealed class AppConfig
|
||||
/// surface a MessageBox — failure to persist a directory choice is user-visible).</summary>
|
||||
public void Save()
|
||||
{
|
||||
Directory.CreateDirectory(ConfigDirectory);
|
||||
Directory.CreateDirectory(UserDataDirectory);
|
||||
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(ConfigPath, json);
|
||||
// Atomic replace — write a temp then move it over, so a torn write (crash / power-loss /
|
||||
// the updater force-closing us mid-save) can't truncate the file and silently revert config
|
||||
// to defaults.
|
||||
var tmp = ConfigPath + ".tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(tmp, json);
|
||||
File.Move(tmp, ConfigPath, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* ignore */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Convenience: build the appropriate <see cref="ProfileStore"/> for the
|
||||
|
||||
@@ -24,9 +24,9 @@ public sealed class ProfileStore
|
||||
public ProfileStore()
|
||||
{
|
||||
var machineFolder = SanitiseFsName(Environment.MachineName);
|
||||
// 2026-06-07: profiles live under config\profiles\<machine>\ (was <exe>\profiles\<machine>\).
|
||||
// 2026-06-10: profiles live under "user settings and logs"\profiles\<machine>\.
|
||||
// AppConfig.MigrateLegacyLayoutIfNeeded moves any pre-existing profiles here at startup.
|
||||
baseDir = Path.Combine(AppContext.BaseDirectory, "config", "profiles", machineFolder);
|
||||
baseDir = Path.Combine(AppConfig.ProfilesBaseDirectory, machineFolder);
|
||||
try { Directory.CreateDirectory(baseDir); }
|
||||
catch { /* permissions; List/Save will surface this when actually used */ }
|
||||
}
|
||||
@@ -128,7 +128,26 @@ public sealed class ProfileStore
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var path = PathFor(profile.Title);
|
||||
var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(path, json);
|
||||
WriteFileAtomic(path, json);
|
||||
}
|
||||
|
||||
/// <summary>Write text crash-safely: write a sibling temp file, then atomically move it over the
|
||||
/// target. A crash, power-loss, or the updater force-closing mid-write then leaves either the old
|
||||
/// file or the complete new one — never a truncated file that the catch-all loaders would
|
||||
/// silently read as a blank profile.</summary>
|
||||
private static void WriteFileAtomic(string path, string contents)
|
||||
{
|
||||
var tmp = path + ".tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(tmp, contents);
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* ignore */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes the profile by title. Returns true if a file was removed,
|
||||
@@ -171,7 +190,7 @@ public sealed class ProfileStore
|
||||
if (profile is null) return false;
|
||||
profile.Title = newTitle;
|
||||
var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(newPath, json);
|
||||
WriteFileAtomic(newPath, json);
|
||||
if (!string.Equals(oldPath, newPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(oldPath);
|
||||
|
||||
@@ -154,7 +154,19 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
foreach (var id in deviceIds)
|
||||
{
|
||||
if (outputs.ContainsKey(id)) continue;
|
||||
if (outputs.TryGetValue(id, out var existing))
|
||||
{
|
||||
if (!existing.Faulted) continue; // already live — leave it running, no audio break
|
||||
// Device faulted mid-stream (unplugged) yet still in the desired set. Tear the dead
|
||||
// output down so the open below re-creates it. SAFETY NET only: normally an unplug
|
||||
// also clears the card's tick, so the remove loop above drops it and replug re-opens
|
||||
// it fresh from the remembered selection (App side) — this branch just covers a fault
|
||||
// where the same id is still desired (e.g. a transient WASAPI invalidation with no
|
||||
// device-state change). If the device is still gone the open fails and it stays absent.
|
||||
onDiagnostic?.Invoke($"output \"{existing.Name}\" faulted — re-opening");
|
||||
outputs.Remove(id);
|
||||
DisposeOutput(existing);
|
||||
}
|
||||
MMDevice? device = null;
|
||||
WasapiOut? wasapi = null;
|
||||
try
|
||||
@@ -176,12 +188,26 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
// Output device buffer. Request 5 ms; shared-mode WASAPI clamps it up to the
|
||||
// device's minimum period (~10 ms on tested hardware, 2026-06-08) — but ~10 ms
|
||||
// is still ~5 ms tighter than the old 15 ms, a free latency win. The per-device
|
||||
// drift corrector keeps this buffer fed from its held ~12 ms cushion, so the
|
||||
// smaller endpoint reserve doesn't risk underruns even on a flaky onboard device.
|
||||
// drift corrector keeps this buffer fed from its held card-sized cushion (≈12 ms
|
||||
// on a typical card), so the smaller endpoint reserve doesn't risk underruns.
|
||||
wasapi = new WasapiOut(device, AudioClientShareMode.Shared, useEventSync: true, latency: 5);
|
||||
var entry = new OutputEntry { Device = device, Output = wasapi, Buffer = buffer, Drift = drift, Name = name };
|
||||
// Notice the device dying mid-stream (USB card unplugged → WASAPI invalidates the
|
||||
// endpoint and raises PlaybackStopped WITH an exception). Just flag it — never take
|
||||
// the gate or dispose from here: this can fire on the device thread during our own
|
||||
// Stop()/DisposeOutput, so doing real work here could deadlock. The next
|
||||
// SetOutputDevices (the hot-plug notifier fires one on unplug AND replug) sees the
|
||||
// flag and tears the dead entry down so the device can be re-opened. A clean stop
|
||||
// (no exception — we asked for it) is ignored: the entry is being removed anyway.
|
||||
wasapi.PlaybackStopped += (_, stopArgs) =>
|
||||
{
|
||||
if (stopArgs.Exception is not { } stopEx) return;
|
||||
entry.Faulted = true;
|
||||
onDiagnostic?.Invoke($"output \"{name}\" lost the device: {stopEx.GetType().Name}: {stopEx.Message}");
|
||||
};
|
||||
wasapi.Init(drift);
|
||||
wasapi.Play();
|
||||
outputs[id] = new OutputEntry { Device = device, Output = wasapi, Buffer = buffer, Drift = drift, Name = name };
|
||||
outputs[id] = entry;
|
||||
onDiagnostic?.Invoke($"output added: \"{name}\"");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -277,6 +303,11 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
public required BufferedWaveProvider Buffer { get; init; }
|
||||
public required DriftResamplingProvider Drift { get; init; }
|
||||
public required string Name { get; init; }
|
||||
// Set true (off-thread, from WasapiOut.PlaybackStopped) when this device dies mid-stream —
|
||||
// typically a USB card unplugged, which invalidates the WASAPI endpoint. SetOutputDevices
|
||||
// reads it to know the entry is dead and must be torn down + re-opened rather than skipped.
|
||||
// Volatile: written on the WASAPI thread, read under the gate without a shared write lock.
|
||||
public volatile bool Faulted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -304,12 +335,23 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
private const double DriftRatioSmoothingNew = 0.30;
|
||||
private const double DriftRatioMin = 0.95;
|
||||
private const double DriftRatioMax = 1.05;
|
||||
// Feedback: steer the buffer toward a known, low cushion. Pure rate-matching holds the
|
||||
// buffer wherever the start-up transient left it (~50 ms and climbing in the field) —
|
||||
// SessionPlayout gets away without this because it ARMS at target and has a click-trim
|
||||
// net; the device buffer has neither, so it needs an explicit depth term. The correction
|
||||
// is tiny (≤0.3 % rate, spread over seconds): a sub-audible pitch nudge, never a click.
|
||||
private const int TargetDepthMs = 12; // operating depth we hold the buffer at
|
||||
// Feedback: steer the buffer toward a cushion. Pure rate-matching holds the buffer wherever
|
||||
// the start-up transient left it (~50 ms and climbing in the field) — SessionPlayout gets
|
||||
// away without this because it ARMS at target and has a click-trim net; the device buffer
|
||||
// has neither, so it needs an explicit depth term. The correction is tiny (≤0.3 % rate,
|
||||
// spread over seconds): a sub-audible pitch nudge, never a click.
|
||||
//
|
||||
// The cushion is sized to the CARD, not hardcoded. A device can't hold a buffer below one
|
||||
// of its own WASAPI pulls — a 10 ms-period card sawtooths by 10 ms, an 18 ms Realtek by 18 —
|
||||
// so the target is the measured pull size × a small margin: a 10 ms card lands at 12 ms (its
|
||||
// long-proven value), an 18 ms card at ~22. Crucially this READS the card and HOLDS: it is
|
||||
// NOT a load-reactive loop, so it never climbs on a CPU/network spike and drops when calm.
|
||||
// The pull is the WINDOW AVERAGE (one coalesced double-pull can't move it), and a hysteresis
|
||||
// band means only a genuine change in the card's pull size ever shifts the target.
|
||||
private const double TargetGulpMultiple = 1.2; // cushion ≈ this × the card's pull size
|
||||
private const int MinTargetDepthMs = 8; // floor for a tiny-pull device
|
||||
private const int MaxTargetDepthMs = 50; // cap so a pathological pull can't run away
|
||||
private const int TargetHysteresisMs = 2; // only move the target on a real ≥2 ms shift
|
||||
private const double DepthCorrectionSec = 15.0; // correct a depth error over ~this long
|
||||
private const double MaxDepthBias = 0.003; // cap the depth nudge at 0.3 % rate
|
||||
|
||||
@@ -332,6 +374,13 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
private double smoothedRatio = 1.0;
|
||||
private bool tracking;
|
||||
private bool firstWindowDone;
|
||||
// Adaptive-but-stable target. pullSumBytes/pullCount accumulate the card's pull sizes over a
|
||||
// window; targetMs is set from their average (× the margin) and then HELD — the hysteresis
|
||||
// band keeps it from flitting. Defaults to 12 ms until the first window measures the card.
|
||||
private long pullSumBytes;
|
||||
private long pullCount;
|
||||
private int targetMs = 12;
|
||||
private double lastGulpMs;
|
||||
|
||||
// Scratch — grown lazily, persists across calls so the hot path doesn't allocate.
|
||||
private byte[] inputBytes = new byte[16384];
|
||||
@@ -367,6 +416,11 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
var outFrames = count / MixBytesPerFrame;
|
||||
if (outFrames <= 0) return 0;
|
||||
|
||||
// Sample the card's pull size for the adaptive target (see UpdateRatioIfDue). Averaged,
|
||||
// so an occasional coalesced double-pull can't distort it.
|
||||
pullSumBytes += count;
|
||||
pullCount++;
|
||||
|
||||
UpdateRatioIfDue();
|
||||
|
||||
var inputFramesNeeded = resampler.ResamplePrepare(outFrames, MixChannels, out var inBuf, out var inBufOff);
|
||||
@@ -435,6 +489,21 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
windowStartFed = fedNow;
|
||||
windowStartDrained = deviceDrainedBytes;
|
||||
|
||||
// Size the target to THIS card from its average pull, then hold it. Done on every path
|
||||
// (including the discarded warm-up window) so the next window's average starts clean.
|
||||
var avgPullBytes = pullCount > 0 ? pullSumBytes / pullCount : 0;
|
||||
pullSumBytes = 0;
|
||||
pullCount = 0;
|
||||
if (avgPullBytes > 0)
|
||||
{
|
||||
lastGulpMs = avgPullBytes / (double)MixBytesPerFrame * 1000.0 / MixSampleRate;
|
||||
var candidateMs = (int)Math.Round(
|
||||
Math.Clamp(lastGulpMs * TargetGulpMultiple, MinTargetDepthMs, MaxTargetDepthMs));
|
||||
// Hysteresis: only move on a genuine ≥2 ms change in the card's pull, so tiny
|
||||
// averaging wobble never nudges the latency — it settles once and stays put.
|
||||
if (Math.Abs(candidateMs - targetMs) >= TargetHysteresisMs) targetMs = candidateMs;
|
||||
}
|
||||
|
||||
// Discard the FIRST completed window. WASAPI primes its endpoint buffer at start-up,
|
||||
// which inflates the device-drain count for that window and reads as a large bogus
|
||||
// ppm (−1199 ppm observed) that shoves the buffer off target. Start measuring from
|
||||
@@ -456,12 +525,12 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
}
|
||||
if (!tracking) return; // nothing valid measured yet — don't touch the rate.
|
||||
|
||||
// Feedback: nudge the buffer toward TargetDepthMs. depthError > 0 = too deep → bias
|
||||
// Feedback: nudge the buffer toward the adaptive target (targetMs). depthError > 0 = too deep → bias
|
||||
// the rate UP so the resampler pulls more per output and drains the buffer faster;
|
||||
// < 0 = too shallow → bias down. Clamped + spread over DepthCorrectionSec so it's a
|
||||
// gentle, inaudible pitch trim, not a per-sample discontinuity.
|
||||
var depthFrames = buffer.BufferedBytes / MixBytesPerFrame;
|
||||
var targetFrames = TargetDepthMs * MixSampleRate / 1000;
|
||||
var targetFrames = targetMs * MixSampleRate / 1000;
|
||||
var depthError = depthFrames - targetFrames;
|
||||
var depthCorrection = Math.Clamp(
|
||||
depthError / (DepthCorrectionSec * MixSampleRate),
|
||||
@@ -475,7 +544,7 @@ internal sealed class MultiOutputPlayout : IRenderBackend
|
||||
var corrPpm = depthCorrection * 1_000_000.0;
|
||||
onDiagnostic?.Invoke(
|
||||
$"\"{name}\": clock={smoothedRatio:F6} ({clockPpm:+0;-0}ppm) depthMs={depthMs} " +
|
||||
$"target={TargetDepthMs} corr={corrPpm:+0;-0}ppm applied={appliedRatio:F6}");
|
||||
$"target={targetMs} gulpMs={lastGulpMs:F0} corr={corrPpm:+0;-0}ppm applied={appliedRatio:F6}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,32 +330,4 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
|
||||
// copy + mix loop, or is it encode + sendto".
|
||||
if (diag) Interlocked.Add(ref cumulativeCaptureTicks, Stopwatch.GetTimestamp() - workStart);
|
||||
}
|
||||
|
||||
/// <summary>Returns the names of all installed ASIO drivers, or an empty list if NAudio
|
||||
/// can't find any. Exposed for the App's driver picker UI.</summary>
|
||||
public static IReadOnlyList<string> EnumerateDriverNames()
|
||||
{
|
||||
try { return AsioOut.GetDriverNames().ToList(); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Briefly opens the named ASIO driver to query its channel counts, then disposes. Single
|
||||
/// driver instance held for ~50 ms while the COM object reads its channel info — does not
|
||||
/// claim the device for streaming. Returns (in,out) = (-1,-1) on any failure (driver not
|
||||
/// installed, busy with another app, etc.). Used by the App to populate channel-pair lists
|
||||
/// in ASIO mode without holding the driver open between user actions.
|
||||
/// </summary>
|
||||
public static (int inputChannels, int outputChannels) ProbeChannelCounts(string driverName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var asio = new AsioOut(driverName);
|
||||
return (asio.DriverInputChannelCount, asio.DriverOutputChannelCount);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (-1, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,16 +91,6 @@ public static class AsioDeviceProbe
|
||||
return new AsioDriverProbeResult(-1, -1, [], []);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backwards-compatibility shim around <see cref="ProbeDriverInfo"/> for callers that only
|
||||
/// need channel counts.
|
||||
/// </summary>
|
||||
public static (int inputChannels, int outputChannels) ProbeChannelCounts(string driverName)
|
||||
{
|
||||
var info = ProbeDriverInfo(driverName);
|
||||
return (info.InputChannelCount, info.OutputChannelCount);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AsioDriverProbeResult(
|
||||
|
||||
@@ -58,6 +58,7 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
|
||||
private readonly object gate = new();
|
||||
|
||||
private WasapiCapture? capture;
|
||||
private MMDevice? captureDevice; // the device backing capture/keepAlive; WE own it and must dispose it (NAudio's WasapiCapture never does)
|
||||
private SilentRenderKeepAlive? keepAlive;
|
||||
private CaptureSourceSpec? activeSpec;
|
||||
private string? captureFormatDescription;
|
||||
@@ -140,6 +141,7 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
var device = enumerator.GetDevice(spec.DeviceId);
|
||||
captureDevice = device; // hold it for disposal in StopInternal — see field comment
|
||||
|
||||
capture = spec.Kind == CaptureKind.Loopback
|
||||
? new LowLatencyWasapiLoopbackCapture(device, audioBufferMilliseconds: CaptureBufferMs)
|
||||
@@ -241,6 +243,14 @@ internal sealed class PushModeWasapiBackend : ICaptureBackend
|
||||
try { keepAlive.Dispose(); } catch { /* ignore */ }
|
||||
keepAlive = null;
|
||||
}
|
||||
// Dispose the device AFTER capture + keepAlive (both hold its COM state). NAudio's
|
||||
// WasapiCapture keeps no reference to the MMDevice and never disposes it, so without this the
|
||||
// device's COM/handle state leaks on every start/stop/switch — the WASAPI handle-leak fingerprint.
|
||||
if (captureDevice is not null)
|
||||
{
|
||||
try { captureDevice.Dispose(); } catch { /* ignore */ }
|
||||
captureDevice = null;
|
||||
}
|
||||
resampler = null;
|
||||
activeSpec = null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user