2026-05-13 15:08:31 +01:00
|
|
|
|
using System.Net;
|
|
|
|
|
|
using System.Net.Sockets;
|
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
|
using NAudio.CoreAudioApi;
|
|
|
|
|
|
using RemSound.Core;
|
|
|
|
|
|
using RemSound.Receiver;
|
|
|
|
|
|
using RemSound.Sender;
|
|
|
|
|
|
|
|
|
|
|
|
namespace RemSound.App;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Main RemSound window. Designed for keyboard / NVDA use.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// UX shape (matches the older RSound app the user asked us to preserve):
|
|
|
|
|
|
/// * Auto-connects on Shown — no Connect button. Discovery starts immediately.
|
|
|
|
|
|
/// * "Connectivity and transport" button opens a settings + peers dialog.
|
|
|
|
|
|
/// * Main form keeps just: mode (send/receive), receive device, volume,
|
|
|
|
|
|
/// send capture devices (CheckedListBox + status label), other actions, status.
|
|
|
|
|
|
/// * Every CheckedListBox has an adjacent status label that announces
|
|
|
|
|
|
/// the focused item, its checked state, position, and "Press Space to toggle".
|
|
|
|
|
|
/// * Knob changes flow live to the audio engine — no engine restarts.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public sealed class MainForm : Form
|
|
|
|
|
|
{
|
|
|
|
|
|
private const string AppName = "RemSound";
|
|
|
|
|
|
|
|
|
|
|
|
// Engines and helpers
|
|
|
|
|
|
private readonly PeerDiscoveryService discovery = new();
|
|
|
|
|
|
private readonly AudioSender sender = new();
|
|
|
|
|
|
private readonly AudioReceiver receiver = new();
|
|
|
|
|
|
private readonly RemSoundSettingsStore settings = new(AppName);
|
|
|
|
|
|
private readonly RemSoundLog logFile = new();
|
|
|
|
|
|
private readonly RemSoundUpdater updater = new();
|
|
|
|
|
|
// Background timer that fires the periodic update-poll. Interval comes from
|
|
|
|
|
|
// AppConfig.UpdateCheckFrequency; "Never" stops the timer entirely. Re-armed by
|
|
|
|
|
|
// ApplyUpdateCheckTimer whenever the user changes the frequency in Preferences.
|
|
|
|
|
|
private readonly System.Windows.Forms.Timer updateCheckTimer = new();
|
|
|
|
|
|
private readonly MainFormHotkeyController hotkeyController;
|
|
|
|
|
|
private readonly MainFormTrayController trayController;
|
2026-05-15 12:40:01 +01:00
|
|
|
|
private readonly RecordingController recordingController;
|
2026-05-22 23:06:07 +01:00
|
|
|
|
// Hook into Windows sleep/resume so the audio backend gets rebuilt after wake. USB
|
|
|
|
|
|
// audio devices (ASIO / WASAPI) commonly come back in a degraded post-resume state
|
|
|
|
|
|
// where the pipeline runs but no sound actually comes out of the interface — restarting
|
|
|
|
|
|
// the backend on resume clears it. Subscribed in the constructor, disposed in FormClosing.
|
|
|
|
|
|
private readonly PowerResumeHandler powerResumeHandler;
|
|
|
|
|
|
// Optional UPnP / NAT-PMP / PCP router-port opener (Mono.Nat under the hood). Off by
|
|
|
|
|
|
// default; the user opts in via the "Automatically open my router for incoming
|
|
|
|
|
|
// connections" tick in Preferences (AppConfig.UpnpEnabled). Started lazily in Shown
|
|
|
|
|
|
// when the flag is on, restarted from OnSystemResume so a sleep-drop on the router's
|
|
|
|
|
|
// NAT table is recovered automatically, and stopped in FormClosing.
|
|
|
|
|
|
private readonly RouterPortMapper routerPortMapper;
|
2026-05-15 12:40:01 +01:00
|
|
|
|
// Menu items for the Record menu kept as fields so RecordingStateChanged can flip
|
|
|
|
|
|
// the visible text + accessibility name between "Start recording" and "Stop recording"
|
|
|
|
|
|
// without rebuilding the menu.
|
|
|
|
|
|
private ToolStripMenuItem? startStopRecordingMenuItem;
|
2026-05-15 17:20:01 +01:00
|
|
|
|
// Held so PopulateRecentProfilesMenu can clear + repopulate it on every DropDownOpening
|
|
|
|
|
|
// (and once during construction so it's not empty before the first open).
|
|
|
|
|
|
private ToolStripMenuItem? recentProfilesMenu;
|
2026-05-13 15:08:31 +01:00
|
|
|
|
|
|
|
|
|
|
// --- Main form controls ---
|
|
|
|
|
|
// Two standalone CheckBoxes for the Send / Receive toggles. Modern .NET (.NET 10) raises
|
|
|
|
|
|
// UIA state-change notifications on CheckBox.Checked changes, so NVDA reliably announces
|
|
|
|
|
|
// "checked" / "not checked" for both spacebar toggles and programmatic toggles (hotkeys,
|
|
|
|
|
|
// tray menu). Replaced an earlier CheckedListBox-based approach that was used to work
|
|
|
|
|
|
// around older WinForms accessibility issues.
|
|
|
|
|
|
// Plain WinForms CheckBox configured exactly like the working RSound.old build:
|
|
|
|
|
|
// * Field initializer sets only AutoSize and the bare Text (no ampersand).
|
|
|
|
|
|
// * Text (with mnemonic) and AccessibleName are then re-assigned in the constructor body.
|
|
|
|
|
|
// This two-step pattern matches the old code byte-for-byte; setting them in the field
|
|
|
|
|
|
// initializer alone was enough to break NVDA state-change announcements.
|
|
|
|
|
|
// * Each box is wrapped in its own FlowLayoutPanel before being placed in the
|
|
|
|
|
|
// TableLayoutPanel cell — same as the old code.
|
|
|
|
|
|
private readonly AccessibleCheckBox receiveAudioCheckbox = new() { Text = "Receive audio", AutoSize = true };
|
|
|
|
|
|
private readonly AccessibleCheckBox sendMyAudioCheckbox = new() { Text = "Send my audio", AutoSize = true };
|
|
|
|
|
|
private readonly TrackBar volumeBar = new() { Minimum = 0, Maximum = 100, TickFrequency = 10, Value = 100, Width = 200 };
|
|
|
|
|
|
// Receive output device. Pre-selected to the system default at startup; user can override
|
|
|
|
|
|
// for the session. Selection is NOT persisted — next session starts on default again.
|
|
|
|
|
|
private readonly CheckedListBox receiveOutputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 };
|
|
|
|
|
|
private readonly Label receiveOutputDevicesStatusLabel = new() { AutoSize = true, Text = "No output device selected." };
|
|
|
|
|
|
// Capture devices the user has ticked for sending. Two lists — render-side outputs (loopback
|
|
|
|
|
|
// capture: system audio / soundcard playback) and capture-side inputs (mics, line-ins). Both
|
|
|
|
|
|
// are summed into one outgoing stream by the sender's MixingEngine. Intentionally NOT
|
|
|
|
|
|
// persisted: every session starts with everything unticked and no audio sent. The user
|
|
|
|
|
|
// re-ticks once per session. Stops any device-routing surprise (a card unplugged between
|
|
|
|
|
|
// runs, IDs changing, etc.).
|
|
|
|
|
|
private readonly CheckedListBox sendOutputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 };
|
|
|
|
|
|
private readonly Label sendOutputDevicesStatusLabel = new() { AutoSize = true, Text = "No output device selected." };
|
|
|
|
|
|
private readonly CheckedListBox sendInputDevicesList = new() { CheckOnClick = true, Width = 430, Height = 90 };
|
|
|
|
|
|
private readonly Label sendInputDevicesStatusLabel = new() { AutoSize = true, Text = "No input device selected." };
|
2026-07-12 10:53:58 +01:00
|
|
|
|
// Per-application WASAPI send (issue #20). The WASAPI send side captures EITHER whole output
|
|
|
|
|
|
// devices (sendOutputDevicesList above — the classic behaviour) OR the audio of specific running
|
|
|
|
|
|
// applications, chosen by sendModeList on the Input/Output tab right after the "Send my audio"
|
|
|
|
|
|
// checkbox. Windows 10 build 19041+ only: on older Windows the chooser row is hidden and the mode
|
|
|
|
|
|
// is forced to "devices". The app list is tracked by process NAME (so a selection survives an app
|
|
|
|
|
|
// restart) and reconciled on sendAppsReconcileTimer so apps dropping in and out don't pile up.
|
|
|
|
|
|
// Unlike the device lists this selection IS persisted — per profile (WasapiSendMode /
|
|
|
|
|
|
// SendAllApplications / SelectedSendApplications) — matching how Ed wants a profile to remember its
|
|
|
|
|
|
// whole send setup.
|
|
|
|
|
|
private readonly ListBox sendModeList = new() { Width = 430, Height = 38, IntegralHeight = false };
|
|
|
|
|
|
private MnemonicLabel? sendModeLabel;
|
|
|
|
|
|
private readonly AccessibleCheckBox sendAllApplicationsCheckbox = new()
|
|
|
|
|
|
{
|
|
|
|
|
|
Text = "Send all applications (Alt+&7)",
|
|
|
|
|
|
AccessibleName = "Send all applications",
|
|
|
|
|
|
AutoSize = true,
|
|
|
|
|
|
Checked = true,
|
|
|
|
|
|
};
|
2026-07-15 23:29:11 +01:00
|
|
|
|
// "Currently active applications" — the apps running right now (like Discovered peers).
|
2026-07-12 10:53:58 +01:00
|
|
|
|
private readonly CheckedListBox sendAppsList = new() { CheckOnClick = true, Width = 430, Height = 90 };
|
|
|
|
|
|
private readonly Label sendAppsStatusLabel = new() { AutoSize = true, Text = "No application selected." };
|
|
|
|
|
|
private MnemonicLabel? sendAppsLabel;
|
2026-07-15 23:29:11 +01:00
|
|
|
|
// "Remembered applications" — the GLOBAL remembered-apps address book (like Remembered peers). Tick one
|
|
|
|
|
|
// here and it's marked to send; the instant it goes live it also appears (ticked) in the active list.
|
|
|
|
|
|
private readonly CheckedListBox rememberedAppsList = new() { CheckOnClick = true, Width = 430, Height = 90 };
|
|
|
|
|
|
private readonly Label rememberedAppsStatusLabel = new() { AutoSize = true, Text = "No remembered application." };
|
|
|
|
|
|
private MnemonicLabel? rememberedAppsLabel;
|
|
|
|
|
|
// The authoritative set of app process-names to send — ticked in EITHER list. Both lists render their
|
|
|
|
|
|
// checkboxes from this and toggling either updates it. Saved per-profile as SelectedSendApplications.
|
|
|
|
|
|
private readonly HashSet<string> selectedSendApps = new(StringComparer.OrdinalIgnoreCase);
|
2026-07-12 10:53:58 +01:00
|
|
|
|
private System.Windows.Forms.Timer? sendAppsReconcileTimer;
|
2026-07-15 19:54:27 +01:00
|
|
|
|
// Instant capture-on-start: fires the moment a ticked app opens an audio session, so we begin
|
|
|
|
|
|
// capturing it right at the start (the poll below is only a backstop). Live only while sending
|
|
|
|
|
|
// specific apps. lastSendAppPidSignature debounces the re-apply so we only reconfigure the engine
|
|
|
|
|
|
// when the ticked apps' actual process ids change (an app opened or closed).
|
|
|
|
|
|
private RemSound.Sender.AudioSessionStartWatcher? sessionStartWatcher;
|
|
|
|
|
|
private string? lastSendAppPidSignature;
|
2026-07-16 00:00:17 +01:00
|
|
|
|
// The active-apps list's FIRST ROW is a synthetic "Send all applications" toggle (Ed's design): ticked =
|
|
|
|
|
|
// send the whole system, and the individual apps below collapse away; unticked reveals them. It's backed
|
|
|
|
|
|
// by sendAllApplicationsCheckbox (kept as hidden state). This reserved process-name marks that row so the
|
|
|
|
|
|
// handlers can tell it apart from a real app.
|
|
|
|
|
|
private const string SendAllAppsSentinel = " |