diff --git a/MANUAL.md b/MANUAL.md
index 57eb30c..1299690 100644
--- a/MANUAL.md
+++ b/MANUAL.md
@@ -699,7 +699,7 @@ Alt+R| Focus the Remembered peers list
Alt+A| Add peer by IP
Alt+S| Focus the Connection status read-out
-(The logging controls — Enable logs and Write logs now — are in the Preferences dialog; reach it via Options → Preferences or Ctrl+P, then use Alt+L / Alt+W within the dialog.)
+(The logging controls — Enable logs, Write logs now and the log-folder housekeeping — are on the Logging tab of the Preferences dialog; reach it via Options → Preferences or Ctrl+P, then use Alt+L / Alt+W within the dialog.)
### Audio inputs and outputs tab
@@ -784,6 +784,7 @@ Send remote receive mute toggle to peers| Tell every connected peer to toggle th
Send Windows global volume up to peers| Tell every connected peer to nudge their _Windows_ volume up by one step (about 2%, the same as their keyboard volume key). This affects every app on the receiving computer, not just RemSound. Hold the hotkey down for bigger jumps. See Remote control.| Unset
Send Windows global volume down to peers| The same, but lowering.| Unset
Send Windows global mute toggle to peers| Tell every connected peer to toggle their Windows mute.| Unset
+Speak the RemSound status information| Read the whole status line out loud through your screen reader — the connection time, how many peers you have, whether sound is flowing, and how healthy the link is — from anywhere, even with RemSound in the tray. Just for screen-reader users; see Hearing the status on demand below.| Unset
You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.
@@ -793,6 +794,14 @@ Once you've given **Quick profile switch** a key, pressing it anywhere pops up a
If RemSound was minimised to the system tray when you pressed the hotkey, it switches the profile and **stays in the tray** — the window doesn't jump up in front of whatever you're doing. So you can change profiles mid-task without losing your place.
+### Hearing the RemSound status on demand
+
+The main window has a **status line** that updates every second with how long you've been connected, how many peers you have, whether sound is flowing, and how healthy the connection is. Normally your screen reader reads it like any other text — but now and then, for reasons that have nothing to do with RemSound, a screen reader loses sight of it and says there's nothing there.
+
+This hotkey is the cure. Give **Speak the RemSound status information** a key in the Keyboard shortcuts dialog, and from then on pressing it reads the whole status line out loud, wherever you are — even when RemSound is tucked away in the tray or another program is in front. It's unset to start with, so the key is yours to choose.
+
+This one is just for screen-reader users: it talks straight through your screen reader. It works with the screen readers RemSound's speech helper supports — **NVDA** , JAWS, Window-Eyes, System Access, SuperNova and ZoomText — and falls back to Windows' own built-in speech if none of those is running. If you don't use a screen reader, just leave this one unset.
+
### Your screen reader reads out the hotkeys
Once a hotkey is set, your screen reader reads it out whenever you land on the menu item or control it's tied to — for example, moving onto **File → Open profile** announces “Ctrl+O”, and a control with a global hotkey announces “press [your key] anywhere”. So you can learn and confirm your shortcuts just by arrowing around the window, without coming back to this dialog.
@@ -954,7 +963,7 @@ RemSound can check for a newer version on a schedule you choose, prompt you to i
### Settings in Preferences
-Open **Options → Preferences** (or Ctrl+P). The update settings sit just above the logging row:
+Open **Options → Preferences** (or Ctrl+P) and go to the **Update settings** tab:
Setting| Shortcut| What it does
---|---|---
@@ -1077,7 +1086,7 @@ OK (Alt+O) saves your choices to the current profile. Cancel (Alt+N) or Esc disc
## 21. Logs and diagnostics
-If logging is turned on (the **Enable logs** checkbox in the Preferences dialog — Options → Preferences, or Ctrl+P — on by default), RemSound writes a log file each session into a `logs` folder inside **user settings and logs** — the same folder your settings and profiles live in. One file per launch.
+Everything to do with logging lives on its own **Logging** tab in the Preferences dialog (Options → Preferences, or Ctrl+P). If logging is turned on (the **Enable logs** checkbox there, on by default), RemSound writes a log file each session into a `logs` folder inside **user settings and logs** — the same folder your settings and profiles live in. One file per launch.
The file contains two kinds of rows:
@@ -1086,7 +1095,17 @@ Kind| Contents
EVT| Event lines — startup, a peer being selected, capture starting, errors, and so on.
SNAP| One-second snapshots of running figures: codec, latency target, how much sound is buffered, packets sent, packets received, drop-outs, drops, and peer round-trip times.
-The **Write logs now** button in the Preferences dialog (Alt+W within that dialog) writes a “user requested write logs now” marker into the log, so you can find that moment in the file afterwards.
+The **Write logs now** button on the Logging tab (Alt+W within the dialog) writes a “user requested write logs now” marker into the log, so you can find that moment in the file afterwards.
+
+### Keeping the logs folder tidy
+
+Log files are small, but if you leave logging on for months they add up. The **Logging** tab has three ways to keep the folder under control, all switched off to begin with so nothing is ever deleted unless you ask for it:
+
+ * **Warn at startup if the logs folder is larger than … megabytes** (Alt+S) — tick this and pick a size, and the next time RemSound starts it checks the folder and pops up a friendly notice if it has grown past that size. It only warns; it never deletes anything itself. The size box (Alt+M) starts at 100 megabytes and stays greyed out until you tick the box.
+ * **Delete logs older than … days old** (Alt+D) — tick this and pick a number of days, from 1 to 30, and each time RemSound starts it quietly clears out any log older than that. The log it's writing right now is never touched. The days box (Alt+Y) starts at 14 and stays greyed out until you tick the box.
+ * **Delete all logs** (Alt+A) — a button that clears out every log file in one go. It asks you to confirm first, Yes or No, then tells you how many it removed. Again, the log RemSound is writing right now is kept; everything else goes.
+
+
Logs are plain text and can be opened in any text editor, or in a spreadsheet. The most useful figures when something feels wrong:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 668921a..84aed2e 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,33 +1,37 @@
-# RemSound v4.2
+# RemSound v4.3
-Three fixes — a freeze when connecting, a new-profile glitch that looked like a crash, and smoother audio on WASAPI.
+Two additions for screen-reader users — hear the status line on demand — and a tidier home for logging.
-## Connecting no longer risks a freeze
+## Hear the RemSound status line on demand
-If you had a peer saved by **name** (rather than a numeric address) and that name couldn't be looked up quickly — an offline peer, or a VPN name while the VPN was down — RemSound used to stall for a few seconds while it waited on the lookup. Because a screen reader waits on the program it's reading, that stall could feel like the whole computer locking up: speech going quiet, keys not responding, then everything coming back a moment later. The name lookup now runs in the background, so connecting — and reconnecting — stays responsive no matter what's in your saved-peers list.
+RemSound's main window has a status line that updates every second: how long you've been connected, how many peers you have, whether sound is flowing, and how healthy the link is. Usually your screen reader reads it like any other text — but every so often, for reasons that have nothing to do with RemSound, it loses track of it and says there's nothing there.
-## Creating a new profile no longer hides the window
+There's now a hotkey for it. In the Keyboard shortcuts dialog (Ctrl+K), set a key for **Speak the RemSound status information**, and pressing it reads the whole status line aloud through your screen reader, from anywhere — even with RemSound hidden in the tray or another program in front. It's unset to begin with, so you pick the key, and it's there purely for screen-reader users.
-Creating a new profile (**Ctrl+N**), or switching profiles, while you had **Start minimised** turned on would drop the freshly-loaded window straight to the tray — so the new profile looked like it had crashed. "Start minimised" is meant for when RemSound first launches, not for something you did on purpose. Now a new profile (or a switch) comes up the normal way, or stays in the tray only if that's where you already were.
+It speaks through whichever screen reader you're running — NVDA, JAWS, Window-Eyes, System Access, SuperNova or ZoomText — and falls back to Windows' own built-in speech if none of those is on.
-## Smoother audio on WASAPI
+## A Logging tab, with folder housekeeping
-RemSound now keeps Windows' timing fine while it's streaming. Without that, Windows can be lazy about waking the audio engine on time, so audio gets delivered in lumpy bursts instead of even steps — which on some machines showed up as breakup, or as latency that crept up over a long session. This fine timing was previously only switched on by **Priority mode**; now the audio path asks for it on its own whenever you're streaming, so you get the smoother delivery without needing that toggle on. (Priority mode's other, heavier options are unchanged and still optional.)
+The logging controls now have their own **Logging** tab in Preferences, and there are three new ways to keep the logs folder from growing without limit — all off unless you turn them on:
+
+- **Warn at startup if the logs folder is larger than a size you choose** (starts at 100 MB) — a friendly notice, nothing deleted.
+- **Delete logs older than a number of days you set** (1 to 30, starts at 14) — tidied automatically when RemSound starts; today's log is always kept.
+- **Delete all logs** — one button, with a Yes/No confirm, that clears every log except the one in use.
## Compatibility
-**v4.2 talks to v3.3 through v4.1 with no trouble** — the over-the-network format is unchanged, 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.)
+**v4.3 talks to v3.3 through v4.2 with no trouble** — the over-the-network format is unchanged, 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-v4.2.zip` from this release.
+1. Download `RemSound-v4.3.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 or recordings.
4. Run `RemSound.exe`.
## Upgrading
-**From v3.6 or newer:** Help → Check for updates installs v4.2 with the in-app updater — and if it can't finish, it puts your old version back exactly as it was.
+**From v3.6 or newer:** Help → Check for updates installs v4.3 with the in-app updater — and if it can't finish, it puts your old version back exactly as it was.
**From v1.9–v3.5:** Check for updates works, but 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.
diff --git a/readme.html b/readme.html
index e61335b..1f32426 100644
--- a/readme.html
+++ b/readme.html
@@ -740,7 +740,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
Alt+S
Focus the Connection status read-out
-
(The logging controls — Enable logs and Write logs now — are in the Preferences dialog; reach it via Options → Preferences or Ctrl+P, then use Alt+L / Alt+W within the dialog.)
+
(The logging controls — Enable logs, Write logs now and the log-folder housekeeping — are on the Logging tab of the Preferences dialog; reach it via Options → Preferences or Ctrl+P, then use Alt+L / Alt+W within the dialog.)
Audio inputs and outputs tab
@@ -826,6 +826,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
Send Windows global volume up to peers
Tell every connected peer to nudge their Windows volume up by one step (about 2%, the same as their keyboard volume key). This affects every app on the receiving computer, not just RemSound. Hold the hotkey down for bigger jumps. See Remote control.
Unset
Send Windows global volume down to peers
The same, but lowering.
Unset
Send Windows global mute toggle to peers
Tell every connected peer to toggle their Windows mute.
Unset
+
Speak the RemSound status information
Read the whole status line out loud through your screen reader — the connection time, how many peers you have, whether sound is flowing, and how healthy the link is — from anywhere, even with RemSound in the tray. Just for screen-reader users; see Hearing the status on demand below.
Unset
You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.
@@ -836,6 +837,14 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
If RemSound was minimised to the system tray when you pressed the hotkey, it switches the profile and stays in the tray — the window doesn't jump up in front of whatever you're doing. So you can change profiles mid-task without losing your place.
+
Hearing the RemSound status on demand
+
+
The main window has a status line that updates every second with how long you've been connected, how many peers you have, whether sound is flowing, and how healthy the connection is. Normally your screen reader reads it like any other text — but now and then, for reasons that have nothing to do with RemSound, a screen reader loses sight of it and says there's nothing there.
+
+
This hotkey is the cure. Give Speak the RemSound status information a key in the Keyboard shortcuts dialog, and from then on pressing it reads the whole status line out loud, wherever you are — even when RemSound is tucked away in the tray or another program is in front. It's unset to start with, so the key is yours to choose.
+
+
This one is just for screen-reader users: it talks straight through your screen reader. It works with the screen readers RemSound's speech helper supports — NVDA, JAWS, Window-Eyes, System Access, SuperNova and ZoomText — and falls back to Windows' own built-in speech if none of those is running. If you don't use a screen reader, just leave this one unset.
+
Your screen reader reads out the hotkeys
Once a hotkey is set, your screen reader reads it out whenever you land on the menu item or control it's tied to — for example, moving onto File → Open profile announces “Ctrl+O”, and a control with a global hotkey announces “press [your key] anywhere”. So you can learn and confirm your shortcuts just by arrowing around the window, without coming back to this dialog.
@@ -1010,7 +1019,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
Settings in Preferences
-
Open Options → Preferences (or Ctrl+P). The update settings sit just above the logging row:
+
Open Options → Preferences (or Ctrl+P) and go to the Update settings tab:
Setting
Shortcut
What it does
@@ -1135,7 +1144,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
21. Logs and diagnostics
-
If logging is turned on (the Enable logs checkbox in the Preferences dialog — Options → Preferences, or Ctrl+P — on by default), RemSound writes a log file each session into a logs folder inside user settings and logs — the same folder your settings and profiles live in. One file per launch.
+
Everything to do with logging lives on its own Logging tab in the Preferences dialog (Options → Preferences, or Ctrl+P). If logging is turned on (the Enable logs checkbox there, on by default), RemSound writes a log file each session into a logs folder inside user settings and logs — the same folder your settings and profiles live in. One file per launch.
The file contains two kinds of rows:
@@ -1145,7 +1154,17 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
SNAP
One-second snapshots of running figures: codec, latency target, how much sound is buffered, packets sent, packets received, drop-outs, drops, and peer round-trip times.
-
The Write logs now button in the Preferences dialog (Alt+W within that dialog) writes a “user requested write logs now” marker into the log, so you can find that moment in the file afterwards.
+
The Write logs now button on the Logging tab (Alt+W within the dialog) writes a “user requested write logs now” marker into the log, so you can find that moment in the file afterwards.
+
+
Keeping the logs folder tidy
+
+
Log files are small, but if you leave logging on for months they add up. The Logging tab has three ways to keep the folder under control, all switched off to begin with so nothing is ever deleted unless you ask for it:
+
+
+
Warn at startup if the logs folder is larger than… megabytes (Alt+S) — tick this and pick a size, and the next time RemSound starts it checks the folder and pops up a friendly notice if it has grown past that size. It only warns; it never deletes anything itself. The size box (Alt+M) starts at 100 megabytes and stays greyed out until you tick the box.
+
Delete logs older than… days old (Alt+D) — tick this and pick a number of days, from 1 to 30, and each time RemSound starts it quietly clears out any log older than that. The log it's writing right now is never touched. The days box (Alt+Y) starts at 14 and stays greyed out until you tick the box.
+
Delete all logs (Alt+A) — a button that clears out every log file in one go. It asks you to confirm first, Yes or No, then tells you how many it removed. Again, the log RemSound is writing right now is kept; everything else goes.
+
Logs are plain text and can be opened in any text editor, or in a spreadsheet. The most useful figures when something feels wrong:
diff --git a/src/RemSound.App/AboutDialog.cs b/src/RemSound.App/AboutDialog.cs
index e05de67..c30434b 100644
--- a/src/RemSound.App/AboutDialog.cs
+++ b/src/RemSound.App/AboutDialog.cs
@@ -20,6 +20,31 @@ internal sealed class AboutDialog : Form
/// updates" path.
private const string ReleaseNotes =
"""
+ RemSound v4.3
+
+ Two additions for screen-reader users, and a
+ tidier home for logging.
+
+ You can now hear the RemSound status line on
+ demand. In the Keyboard shortcuts dialog set a
+ key for "Speak the RemSound status information",
+ and pressing it reads the whole status line aloud
+ through your screen reader — how long you've been
+ connected, your peers, whether sound is flowing,
+ and how healthy the link is — from anywhere, even
+ with RemSound in the tray. It's there for the
+ times your screen reader can't read the status
+ line itself. Unset to begin with, so you pick the
+ key.
+
+ Logging now has its own tab in Preferences, and it
+ can keep its own folder tidy: warn you at startup
+ if the logs folder grows past a size you choose,
+ automatically delete logs older than a number of
+ days you set, and a "Delete all logs" button to
+ clear them out at once. All three are off unless
+ you turn them on.
+
RemSound v4.2
Three fixes, one of them a real annoyance gone.
diff --git a/src/RemSound.App/IScreenReaderOutput.cs b/src/RemSound.App/IScreenReaderOutput.cs
new file mode 100644
index 0000000..b9cc877
--- /dev/null
+++ b/src/RemSound.App/IScreenReaderOutput.cs
@@ -0,0 +1,24 @@
+namespace RemSound.App;
+
+///
+/// Abstraction over a screen-reader speech backend, so RemSound can speak text straight to whatever
+/// screen reader is running (NVDA, JAWS, SAPI, ...). Needed for feedback the screen reader can't
+/// otherwise observe — most importantly a GLOBAL hotkey firing while RemSound isn't focused, where
+/// NVDA reads nothing on its own.
+///
+/// Kept as an interface purely so the concrete backend can be swapped later without touching callers.
+/// Today the only implementation is , which works on every
+/// Windows version RemSound supports (including Windows 7). Prism (evaluated 2026-06-19) is the more
+/// modern option but hard-requires Windows 10+, so it can't replace Tolk while Win7 is supported — if
+/// that changes, add a PrismScreenReaderOutput and pick it per-OS in .
+///
+internal interface IScreenReaderOutput
+{
+ /// Speak through the active screen reader.
+ /// true cuts off whatever it's currently saying. Returns true if the text was handed to a screen
+ /// reader, false if none is available. Best-effort — never throws.
+ bool Speak(string text, bool interrupt = true);
+
+ /// Release the backend. Safe to call more than once.
+ void Shutdown();
+}
diff --git a/src/RemSound.App/LogMaintenance.cs b/src/RemSound.App/LogMaintenance.cs
new file mode 100644
index 0000000..283ca53
--- /dev/null
+++ b/src/RemSound.App/LogMaintenance.cs
@@ -0,0 +1,85 @@
+using RemSound.Core;
+
+namespace RemSound.App;
+
+///
+/// Housekeeping for the logs\ folder, driven by the Logging-tab preferences: report the
+/// folder's total size (for the startup over-size warning), prune log files older than N days, and
+/// delete every log on demand. All operations are best-effort — a locked or vanished file is
+/// skipped, never thrown, because log housekeeping must never stop the app from running.
+///
+internal static class LogMaintenance
+{
+ /// Total size in bytes of every *.log file in the logs folder. 0 if the folder
+ /// doesn't exist yet or can't be read.
+ public static long LogsFolderSizeBytes()
+ {
+ try
+ {
+ var dir = AppConfig.LogsDirectory;
+ if (!Directory.Exists(dir)) return 0;
+ long total = 0;
+ foreach (var file in Directory.EnumerateFiles(dir, "*.log"))
+ {
+ try { total += new FileInfo(file).Length; } catch { /* vanished mid-scan — skip */ }
+ }
+ return total;
+ }
+ catch { return 0; }
+ }
+
+ /// Delete *.log files last written more than days ago.
+ /// The currently-open log () is always spared even if the clock
+ /// makes it look old. Returns how many files were deleted.
+ public static int PruneLogsOlderThan(int days, string? activeLogPath)
+ {
+ if (days < 1) return 0;
+ var deleted = 0;
+ try
+ {
+ var dir = AppConfig.LogsDirectory;
+ if (!Directory.Exists(dir)) return 0;
+ var cutoff = DateTime.Now.AddDays(-days);
+ foreach (var file in Directory.EnumerateFiles(dir, "*.log"))
+ {
+ if (IsSamePath(file, activeLogPath)) continue;
+ try
+ {
+ if (File.GetLastWriteTime(file) < cutoff)
+ {
+ File.Delete(file);
+ deleted++;
+ }
+ }
+ catch { /* locked / in use — leave it */ }
+ }
+ }
+ catch { /* folder enumeration failed — give up quietly */ }
+ return deleted;
+ }
+
+ /// Delete every *.log file except the one currently being written
+ /// (, which is held open and can't be removed anyway). Returns
+ /// how many files were deleted.
+ public static int DeleteAllLogs(string? activeLogPath)
+ {
+ var deleted = 0;
+ try
+ {
+ var dir = AppConfig.LogsDirectory;
+ if (!Directory.Exists(dir)) return 0;
+ foreach (var file in Directory.EnumerateFiles(dir, "*.log"))
+ {
+ if (IsSamePath(file, activeLogPath)) continue;
+ try { File.Delete(file); deleted++; }
+ catch { /* locked / in use — leave it */ }
+ }
+ }
+ catch { /* give up quietly */ }
+ return deleted;
+ }
+
+ private static bool IsSamePath(string a, string? b) =>
+ !string.IsNullOrEmpty(b)
+ && string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs
index 526fdfb..53b1996 100644
--- a/src/RemSound.App/MainForm.cs
+++ b/src/RemSound.App/MainForm.cs
@@ -716,7 +716,10 @@ public sealed class MainForm : Form
() => SendRemoteControl(RemoteControlKind.SystemVolumeUp, 0),
() => SendRemoteControl(RemoteControlKind.SystemVolumeDown, 0),
() => SendRemoteControl(RemoteControlKind.SystemMuteToggle, 0),
- ShowQuickProfileSwitch);
+ ShowQuickProfileSwitch,
+ // Speak the status line aloud through the active screen reader (issue #13). Screen-reader
+ // specific; the global hotkey is unset by default (the user binds it in Keyboard shortcuts).
+ SpeakStatusLine);
// Pipe hotkey controller diagnostics into the main log so we can see, e.g.,
// "capture send-system-volume-down: OK = Ctrl+Shift+Alt+J" and
// "register send-system-volume-down: FAILED = Ctrl+Shift+Alt+J (Win32 error 1409:
@@ -1235,6 +1238,7 @@ public sealed class MainForm : Form
}
hotkeyController.Dispose();
+ ScreenReader.Shutdown();
trayController.Dispose();
logFile.Dispose();
};
@@ -1379,11 +1383,54 @@ public sealed class MainForm : Form
if (IsDisposed) return;
MaybeShowWhatsNewAfterUpdate();
if (IsDisposed) return;
+ MaybeRunLogHousekeeping();
+ if (IsDisposed) return;
MaybeWarnAboutRealtekAsio();
if (IsDisposed) return;
MaybeWarnMicBlockedOnStartup();
}
+ /// Startup log-folder housekeeping driven by the Logging-tab preferences. Both steps are
+ /// opt-in (off by default): first prune logs older than the configured age, then warn if the
+ /// folder still exceeds the configured size. Best-effort — failures never block launch.
+ private void MaybeRunLogHousekeeping()
+ {
+ if (IsDisposed) return;
+ AppConfig cfg;
+ try { cfg = AppConfig.Load(); }
+ catch { return; }
+
+ if (cfg.PruneOldLogs)
+ {
+ var removed = LogMaintenance.PruneLogsOlderThan(cfg.PruneOldLogsDays, logFile.Path);
+ if (removed > 0)
+ logFile.Event($"log housekeeping: pruned {removed} log(s) older than {cfg.PruneOldLogsDays} day(s)");
+ }
+
+ if (cfg.WarnIfLogsFolderExceeds)
+ {
+ var bytes = LogMaintenance.LogsFolderSizeBytes();
+ var limitBytes = (long)cfg.LogsFolderWarnThresholdMb * 1024 * 1024;
+ if (bytes > limitBytes)
+ {
+ var mb = bytes / (1024.0 * 1024.0);
+ logFile.Event($"log housekeeping: logs folder {mb:0.#} MB exceeds {cfg.LogsFolderWarnThresholdMb} MB threshold — warning user");
+ var page = new TaskDialogPage
+ {
+ Caption = "RemSound",
+ Heading = "Logs folder is getting large",
+ Text = $"The RemSound logs folder is using about {mb:0} MB, which is over your {cfg.LogsFolderWarnThresholdMb} MB warning size.\n\n"
+ + "You can clear old logs from Preferences, on the Logging tab.",
+ Icon = TaskDialogIcon.Warning,
+ Buttons = { TaskDialogButton.OK },
+ AllowCancel = true,
+ };
+ try { ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)); }
+ catch (Exception ex) { logFile.Event($"log housekeeping: warn dialog failed: {ex.GetType().Name}: {ex.Message}"); }
+ }
+ }
+ }
+
/// If the user opted in () and the
/// running version changed since the last launch we recorded, open the About box once so
/// they see what changed in the update just installed. Always records the current version
@@ -2242,6 +2289,13 @@ public sealed class MainForm : Form
UpdateDiagnosticsGate();
},
writeLogsNow: () => logFile.Event("user requested write logs now"),
+ deleteAllLogs: () =>
+ {
+ // Spare the log we're currently writing (it's held open and can't be removed anyway).
+ var removed = LogMaintenance.DeleteAllLogs(logFile.Path);
+ logFile.Event($"user deleted all logs from Preferences ({removed} file(s) removed)");
+ return removed;
+ },
checkForUpdatesNow: () => CheckForUpdatesManually(),
onUpdateFrequencyChanged: ApplyUpdateCheckTimer,
applyUpnpEnabled: enabled =>
@@ -3130,6 +3184,21 @@ public sealed class MainForm : Form
statusReadout.Text = text;
}
+ /// Speak the current connection status line aloud through the active screen reader, via
+ /// Tolk (). Answers issue #13: NVDA sometimes can't see the status
+ /// readout ("no status line found"), so this reads it on demand. Triggered only by a user-set
+ /// global hotkey (screen-reader only, unset by default) — being a system-wide hotkey it works from
+ /// anywhere, whether or not RemSound is focused. Newlines become ". " so the multi-line readout
+ /// speaks as a sentence rather than running together.
+ private void SpeakStatusLine()
+ {
+ var text = statusReadout.Text;
+ text = string.IsNullOrWhiteSpace(text)
+ ? "No status information available."
+ : text.Replace("\r\n", ". ").Replace("\n", ". ");
+ ScreenReader.Speak(text);
+ }
+
private string ComputeStatusText()
{
// Compute byte-rates from delta since last sample. First call has no baseline so
diff --git a/src/RemSound.App/MainFormHotkeyController.cs b/src/RemSound.App/MainFormHotkeyController.cs
index 82f6d8f..6b01fe9 100644
--- a/src/RemSound.App/MainFormHotkeyController.cs
+++ b/src/RemSound.App/MainFormHotkeyController.cs
@@ -31,6 +31,10 @@ internal sealed class MainFormHotkeyController : IDisposable
private readonly Action sendSystemVolumeDown;
private readonly Action sendSystemMuteToggle;
private readonly Action quickProfileSwitch;
+ // Speak the connection status line aloud through the active screen reader (Tolk). Screen-reader
+ // specific (issue #13); unset by default. Global so it reads the status even when RemSound isn't
+ // focused — the case NVDA can't otherwise cover.
+ private readonly Action speakStatusLine;
private Form? owner;
private HotkeyInfo sendMuteHotkey;
private HotkeyInfo receiveMuteHotkey;
@@ -45,6 +49,7 @@ internal sealed class MainFormHotkeyController : IDisposable
private HotkeyInfo systemVolumeDownHotkey;
private HotkeyInfo systemMuteToggleHotkey;
private HotkeyInfo quickProfileSwitchHotkey;
+ private HotkeyInfo speakStatusLineHotkey;
private GlobalHotkey? sendMuteGlobalHotkey;
private GlobalHotkey? receiveMuteGlobalHotkey;
private GlobalHotkey? trayGlobalHotkey;
@@ -58,6 +63,7 @@ internal sealed class MainFormHotkeyController : IDisposable
private GlobalHotkey? systemVolumeDownGlobalHotkey;
private GlobalHotkey? systemMuteToggleGlobalHotkey;
private GlobalHotkey? quickProfileSwitchGlobalHotkey;
+ private GlobalHotkey? speakStatusLineGlobalHotkey;
/// Optional log sink. MainForm wires this to logFile.Event(...) so each
/// hotkey change writes a clear trail of "user opened capture", "captured X", "registered X
@@ -87,7 +93,8 @@ internal sealed class MainFormHotkeyController : IDisposable
Action sendSystemVolumeUp,
Action sendSystemVolumeDown,
Action sendSystemMuteToggle,
- Action quickProfileSwitch)
+ Action quickProfileSwitch,
+ Action speakStatusLine)
{
this.settingsStore = settingsStore;
this.toggleSend = toggleSend;
@@ -103,6 +110,7 @@ internal sealed class MainFormHotkeyController : IDisposable
this.sendSystemVolumeDown = sendSystemVolumeDown;
this.sendSystemMuteToggle = sendSystemMuteToggle;
this.quickProfileSwitch = quickProfileSwitch;
+ this.speakStatusLine = speakStatusLine;
sendMuteHotkey = settingsStore.LoadSendMuteHotkey();
receiveMuteHotkey = settingsStore.LoadReceiveMuteHotkey();
trayHotkey = settingsStore.LoadTrayHotkey();
@@ -116,6 +124,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemVolumeDownHotkey = settingsStore.LoadSystemVolumeDownHotkey();
systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey();
quickProfileSwitchHotkey = settingsStore.LoadQuickProfileSwitchHotkey();
+ speakStatusLineHotkey = settingsStore.LoadSpeakStatusLineHotkey();
}
public void Initialize(Form ownerForm)
@@ -134,6 +143,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
systemMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm);
quickProfileSwitchGlobalHotkey = new GlobalHotkey(ownerForm);
+ speakStatusLineGlobalHotkey = new GlobalHotkey(ownerForm);
sendMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleSend);
receiveMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleReceive);
trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray);
@@ -147,6 +157,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeDown);
systemMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemMuteToggle);
quickProfileSwitchGlobalHotkey.Pressed += () => InvokeOnOwner(quickProfileSwitch);
+ speakStatusLineGlobalHotkey.Pressed += () => InvokeOnOwner(speakStatusLine);
RegisterSendMuteHotkey();
RegisterReceiveMuteHotkey();
RegisterTrayHotkey();
@@ -160,6 +171,7 @@ internal sealed class MainFormHotkeyController : IDisposable
RegisterSystemVolumeDownHotkey();
RegisterSystemMuteToggleHotkey();
RegisterQuickProfileSwitchHotkey();
+ RegisterSpeakStatusLineHotkey();
}
public void ShowKeyboardShortcutsDialog(IWin32Window dialogOwner)
@@ -269,6 +281,7 @@ internal sealed class MainFormHotkeyController : IDisposable
list.Items.Add($"Send Windows global volume down to peers: {systemVolumeDownHotkey}");
list.Items.Add($"Send Windows global mute toggle to peers: {systemMuteToggleHotkey}");
list.Items.Add($"Quick profile switch (open a list of all profiles): {quickProfileSwitchHotkey}");
+ list.Items.Add($"Speak the RemSound status information from anywhere (screen reader only): {speakStatusLineHotkey}");
if (prev >= 0 && prev < list.Items.Count)
{
list.SelectedIndex = prev;
@@ -306,6 +319,7 @@ internal sealed class MainFormHotkeyController : IDisposable
case 10: ChangeSystemVolumeDownHotkey(dialog); break;
case 11: ChangeSystemMuteToggleHotkey(dialog); break;
case 12: ChangeQuickProfileSwitchHotkey(dialog); break;
+ case 13: ChangeSpeakStatusLineHotkey(dialog); break;
default: return;
}
RefreshList();
@@ -338,6 +352,7 @@ internal sealed class MainFormHotkeyController : IDisposable
case 10: ApplyUnset("send-system-volume-down", h => systemVolumeDownHotkey = h, RegisterSystemVolumeDownHotkey, settingsStore.SaveSystemVolumeDownHotkey); break;
case 11: ApplyUnset("send-system-mute-toggle", h => systemMuteToggleHotkey = h, RegisterSystemMuteToggleHotkey, settingsStore.SaveSystemMuteToggleHotkey); break;
case 12: ApplyUnset("quick-profile-switch", h => quickProfileSwitchHotkey = h, RegisterQuickProfileSwitchHotkey, settingsStore.SaveQuickProfileSwitchHotkey); break;
+ case 13: ApplyUnset("speak-status-line", h => speakStatusLineHotkey = h, RegisterSpeakStatusLineHotkey, settingsStore.SaveSpeakStatusLineHotkey); break;
default: return;
}
RefreshList();
@@ -418,6 +433,7 @@ internal sealed class MainFormHotkeyController : IDisposable
systemVolumeDownGlobalHotkey?.Dispose();
systemMuteToggleGlobalHotkey?.Dispose();
quickProfileSwitchGlobalHotkey?.Dispose();
+ speakStatusLineGlobalHotkey?.Dispose();
}
public HotkeyInfo SendMuteHotkey => sendMuteHotkey;
@@ -433,6 +449,7 @@ internal sealed class MainFormHotkeyController : IDisposable
public HotkeyInfo SystemVolumeDownHotkey => systemVolumeDownHotkey;
public HotkeyInfo SystemMuteToggleHotkey => systemMuteToggleHotkey;
public HotkeyInfo QuickProfileSwitchHotkey => quickProfileSwitchHotkey;
+ public HotkeyInfo SpeakStatusLineHotkey => speakStatusLineHotkey;
/// Open the capture dialog, log what came back, and (on a successful capture)
/// run with the captured hotkey. Centralises the boilerplate
@@ -571,6 +588,13 @@ internal sealed class MainFormHotkeyController : IDisposable
settingsStore.SaveQuickProfileSwitchHotkey(h);
});
+ private void ChangeSpeakStatusLineHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "speak-status-line", h =>
+ {
+ speakStatusLineHotkey = h;
+ RegisterSpeakStatusLineHotkey();
+ settingsStore.SaveSpeakStatusLineHotkey(h);
+ });
+
// Hotkeys come in two flavours and need different Windows-side registration:
// * Toggle hotkeys (mute, tray show/hide) — re-firing on hold would flip state back
// and forth. Registered with MOD_NOREPEAT (allowRepeat=false). One press, one fire.
@@ -599,6 +623,9 @@ internal sealed class MainFormHotkeyController : IDisposable
// Quick profile switch is a one-shot (press → open the popup); MOD_NOREPEAT (the default) keeps
// a held key from re-opening it repeatedly.
private void RegisterQuickProfileSwitchHotkey() => RegisterIfSet(quickProfileSwitchGlobalHotkey, quickProfileSwitchHotkey, "quick profile switch");
+ // Speak status line is a one-shot (press → read the status aloud once); MOD_NOREPEAT (the default)
+ // keeps a held key from re-triggering the speech over and over.
+ private void RegisterSpeakStatusLineHotkey() => RegisterIfSet(speakStatusLineGlobalHotkey, speakStatusLineHotkey, "speak status line");
private void RegisterIfSet(GlobalHotkey? globalHotkey, HotkeyInfo hotkey, string description, bool allowRepeat = false)
{
diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs
index d649552..2a86406 100644
--- a/src/RemSound.App/PreferencesDialog.cs
+++ b/src/RemSound.App/PreferencesDialog.cs
@@ -293,6 +293,60 @@ internal sealed class PreferencesDialog : Form
AutoSize = true,
};
+ // --- Logging tab: log-folder housekeeping (2026-06-19). All machine-local (AppConfig). The two
+ // spinners are greyed out until their enabling checkbox is ticked. Mnemonics on this tab: L, W,
+ // S, M, D, Y, A — all distinct (tab scope is per-page, so reuse elsewhere is fine). ---
+ private readonly AccessibleCheckBox warnIfLogsExceedBox = new()
+ {
+ Text = "Warn at startup if the logs folder is larger than (Alt+&S)",
+ AccessibleName = "Warn at startup if the logs folder is larger than",
+ AutoSize = true,
+ };
+ private readonly NumericUpDown logsSizeLimitBox = new()
+ {
+ Minimum = 1,
+ Maximum = 100000,
+ Increment = 10,
+ Value = 100,
+ Width = 90,
+ AccessibleName = "Warn when the logs folder is larger than this many megabytes (Alt+M)",
+ };
+ private readonly MnemonicLabel logsSizeUnitLabel = new()
+ {
+ Text = "&megabytes",
+ AutoSize = true,
+ Anchor = AnchorStyles.Left,
+ Padding = new Padding(6, 6, 0, 0),
+ };
+ private readonly AccessibleCheckBox pruneOldLogsBox = new()
+ {
+ Text = "Delete logs older than (Alt+&D)",
+ AccessibleName = "Delete logs older than",
+ AutoSize = true,
+ };
+ private readonly NumericUpDown pruneDaysBox = new()
+ {
+ Minimum = 1,
+ Maximum = 30,
+ Increment = 1,
+ Value = 14,
+ Width = 70,
+ AccessibleName = "Delete logs older than this many days (Alt+Y)",
+ };
+ private readonly MnemonicLabel pruneDaysUnitLabel = new()
+ {
+ Text = "da&ys old",
+ AutoSize = true,
+ Anchor = AnchorStyles.Left,
+ Padding = new Padding(6, 6, 0, 0),
+ };
+ private readonly Button deleteAllLogsButton = new()
+ {
+ Text = "Delete &all logs",
+ AccessibleName = "Delete all logs",
+ AutoSize = true,
+ };
+
// Startup behaviour (moved here from the Options-menu StartupBehaviourDialog, 2026-06-13). These
// live on their own tab; their Alt-letters are isolated per tab so reusing M/A/P/L is fine.
private readonly AccessibleCheckBox startMinimisedBox = new()
@@ -353,6 +407,7 @@ internal sealed class PreferencesDialog : Form
Func getLoggingEnabled,
Action applyLoggingEnabled,
Action writeLogsNow,
+ Func deleteAllLogs,
Action checkForUpdatesNow,
Action onUpdateFrequencyChanged,
Action applyUpnpEnabled,
@@ -585,6 +640,70 @@ internal sealed class PreferencesDialog : Form
writeLogsNowButton.Click += (_, _) => writeLogsNow();
+ // --- Logging housekeeping (machine-local; each control writes through to AppConfig on
+ // change, like the Update-settings controls above). The two spinners follow their
+ // checkbox's enabled state so they're only editable when the feature is on. ---
+ warnIfLogsExceedBox.Checked = cfgForLoad.WarnIfLogsFolderExceeds;
+ logsSizeLimitBox.Value = Math.Clamp(cfgForLoad.LogsFolderWarnThresholdMb, (int)logsSizeLimitBox.Minimum, (int)logsSizeLimitBox.Maximum);
+ logsSizeLimitBox.Enabled = warnIfLogsExceedBox.Checked;
+ logsSizeUnitLabel.MnemonicTarget = logsSizeLimitBox;
+ warnIfLogsExceedBox.CheckedChanged += (_, _) =>
+ {
+ var cfg = AppConfig.Load();
+ cfg.WarnIfLogsFolderExceeds = warnIfLogsExceedBox.Checked;
+ TrySaveConfig(cfg);
+ logsSizeLimitBox.Enabled = warnIfLogsExceedBox.Checked;
+ };
+ logsSizeLimitBox.ValueChanged += (_, _) =>
+ {
+ var cfg = AppConfig.Load();
+ cfg.LogsFolderWarnThresholdMb = (int)logsSizeLimitBox.Value;
+ TrySaveConfig(cfg);
+ };
+
+ pruneOldLogsBox.Checked = cfgForLoad.PruneOldLogs;
+ pruneDaysBox.Value = Math.Clamp(cfgForLoad.PruneOldLogsDays, (int)pruneDaysBox.Minimum, (int)pruneDaysBox.Maximum);
+ pruneDaysBox.Enabled = pruneOldLogsBox.Checked;
+ pruneDaysUnitLabel.MnemonicTarget = pruneDaysBox;
+ pruneOldLogsBox.CheckedChanged += (_, _) =>
+ {
+ var cfg = AppConfig.Load();
+ cfg.PruneOldLogs = pruneOldLogsBox.Checked;
+ TrySaveConfig(cfg);
+ pruneDaysBox.Enabled = pruneOldLogsBox.Checked;
+ };
+ pruneDaysBox.ValueChanged += (_, _) =>
+ {
+ var cfg = AppConfig.Load();
+ cfg.PruneOldLogsDays = (int)pruneDaysBox.Value;
+ TrySaveConfig(cfg);
+ };
+
+ deleteAllLogsButton.Click += (_, _) =>
+ {
+ var confirm = new TaskDialogPage
+ {
+ Caption = "RemSound",
+ Heading = "Delete all logs?",
+ Text = "This permanently deletes every log file in the logs folder except the one currently in use. This cannot be undone.",
+ Icon = TaskDialogIcon.Warning,
+ Buttons = { TaskDialogButton.Yes, TaskDialogButton.No },
+ DefaultButton = TaskDialogButton.No,
+ AllowCancel = true,
+ };
+ if (TaskDialog.ShowDialog(this, confirm) != TaskDialogButton.Yes) return;
+ var removed = deleteAllLogs();
+ var done = new TaskDialogPage
+ {
+ Caption = "RemSound",
+ Heading = "Logs deleted",
+ Text = removed == 1 ? "Deleted 1 log file." : $"Deleted {removed} log files.",
+ Icon = TaskDialogIcon.Information,
+ Buttons = { TaskDialogButton.OK },
+ };
+ TaskDialog.ShowDialog(this, done);
+ };
+
closeButton.Click += (_, _) => Close();
// Wire up the Startup behaviour tab (moved here from the old Options-menu dialog).
@@ -650,16 +769,46 @@ internal sealed class PreferencesDialog : Form
startupListPanel.Controls.Add(startupProfileListLabel);
startupListPanel.Controls.Add(startupProfileList);
- // Four tabs, accessible (QuietTabControl) like the main window. Ctrl+Tab / arrows on the
+ // Logging tab rows: each spinner sits with its unit label on its own indented row beneath the
+ // checkbox that enables it (number box first, then the "megabytes"/"days old" unit label —
+ // matching the natural "...larger than [100] megabytes" reading order).
+ var logsSizeRow = new FlowLayoutPanel
+ {
+ AutoSize = true,
+ Dock = DockStyle.Fill,
+ FlowDirection = FlowDirection.LeftToRight,
+ WrapContents = false,
+ Padding = new Padding(20, 2, 0, 0),
+ };
+ logsSizeRow.Controls.Add(logsSizeLimitBox);
+ logsSizeRow.Controls.Add(logsSizeUnitLabel);
+ var pruneDaysRow = new FlowLayoutPanel
+ {
+ AutoSize = true,
+ Dock = DockStyle.Fill,
+ FlowDirection = FlowDirection.LeftToRight,
+ WrapContents = false,
+ Padding = new Padding(20, 2, 0, 0),
+ };
+ pruneDaysRow.Controls.Add(pruneDaysBox);
+ pruneDaysRow.Controls.Add(pruneDaysUnitLabel);
+
+ // Five tabs, accessible (QuietTabControl) like the main window. Ctrl+Tab / arrows on the
// strip switch tabs; the active page's controls are the next tab stops. The control itself
- // is a field (declared above) so OnShown can focus it when the dialog opens.
+ // is a field (declared above) so OnShown can focus it when the dialog opens. Logging is its
+ // own tab (2026-06-19); the two logging controls moved off the General tab to lead it.
tabs.TabPages.Add(MakeTab("General",
- browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel, loggingBox, writeLogsNowButton));
+ browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel));
tabs.TabPages.Add(MakeTab("Audio cues", cueGroup));
tabs.TabPages.Add(MakeTab("Startup behaviour",
startMinimisedBox, startWithUserBox, startWithProfileBox, startupListPanel));
tabs.TabPages.Add(MakeTab("Update settings",
checkForUpdatesOnStartupBox, freqRow, checkForUpdatesNowButton, silentlyInstallUpdatesBox, showWhatsNewAfterUpdateBox));
+ tabs.TabPages.Add(MakeTab("Logging",
+ loggingBox, writeLogsNowButton,
+ warnIfLogsExceedBox, logsSizeRow,
+ pruneOldLogsBox, pruneDaysRow,
+ deleteAllLogsButton));
var buttons = new FlowLayoutPanel
{
diff --git a/src/RemSound.App/RemSound.App.csproj b/src/RemSound.App/RemSound.App.csproj
index 2ee20df..9ff3530 100644
--- a/src/RemSound.App/RemSound.App.csproj
+++ b/src/RemSound.App/RemSound.App.csproj
@@ -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. -->
- 4.2
+ 4.3
@@ -79,6 +79,17 @@
readme.html
PreserveNewest
+
+
+ %(Filename)%(Extension)
+ PreserveNewest
+ PreserveNewest
+
+
+
+ <_TolkFiles Include="..\..\tolk\Tolk.dll;..\..\tolk\nvdaControllerClient64.dll;..\..\tolk\SAAPI64.dll;..\..\tolk\LICENSE-Tolk.txt" />
+
+
+
diff --git a/src/RemSound.App/ScreenReader.cs b/src/RemSound.App/ScreenReader.cs
new file mode 100644
index 0000000..69d70e7
--- /dev/null
+++ b/src/RemSound.App/ScreenReader.cs
@@ -0,0 +1,45 @@
+namespace RemSound.App;
+
+///
+/// Process-wide screen-reader speech. Used for feedback the screen reader can't otherwise observe —
+/// chiefly the "speak the status line" hotkey (GitHub issue #13), which must read aloud even when the
+/// global hotkey fires while RemSound isn't the focused window.
+///
+/// Holds a single backend, created once on first use. Today that is always Tolk, which works on every
+/// Windows version RemSound supports — including Windows 7. The interface seam exists so a future build
+/// can choose a different backend per OS without changing any caller: branch inside
+/// . (Prism is the modern successor to Tolk but requires Windows 10+, so it
+/// can't be the default while Win7 is supported — see .)
+///
+internal static class ScreenReader
+{
+ private static readonly object sync = new();
+ private static IScreenReaderOutput? backend;
+
+ private static IScreenReaderOutput Backend
+ {
+ get { lock (sync) { return backend ??= CreateBackend(); } }
+ }
+
+ private static IScreenReaderOutput CreateBackend()
+ {
+ // Win7-safe default. To adopt Prism on Windows 10+ later, branch on Environment.OSVersion
+ // here and return a PrismScreenReaderOutput when the OS is >= Windows 10 — callers below stay
+ // exactly as they are.
+ return new TolkScreenReaderOutput();
+ }
+
+ /// Speak text through the active screen reader (best-effort; silent if none is running).
+ /// Returns true if it reached a screen reader.
+ public static bool Speak(string text, bool interrupt = true) => Backend.Speak(text, interrupt);
+
+ /// Release the backend on app shutdown. Safe to call when nothing was ever spoken.
+ public static void Shutdown()
+ {
+ lock (sync)
+ {
+ backend?.Shutdown();
+ backend = null;
+ }
+ }
+}
diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs
index 4b95b8d..2ec3893 100644
--- a/src/RemSound.App/SelfTest.cs
+++ b/src/RemSound.App/SelfTest.cs
@@ -335,7 +335,7 @@ internal static class SelfTest
("Recording settings", () => new RecordingSettingsDialog(new RecordingSettings())),
("Preferences", () => new PreferencesDialog(
new RemSoundSettingsStore("RemSound"), null,
- () => false, _ => { }, () => { }, () => { }, () => { }, _ => { },
+ () => false, _ => { }, () => { }, () => 0, () => { }, () => { }, _ => { },
() => (default(RouterMappingStatus), (IPEndPoint?)null, ""),
_ => { }, _ => { })),
};
diff --git a/src/RemSound.App/TolkScreenReaderOutput.cs b/src/RemSound.App/TolkScreenReaderOutput.cs
new file mode 100644
index 0000000..498554a
--- /dev/null
+++ b/src/RemSound.App/TolkScreenReaderOutput.cs
@@ -0,0 +1,70 @@
+using System.Runtime.InteropServices;
+
+namespace RemSound.App;
+
+///
+/// Tolk-backed . Tolk (https://github.com/dkager/tolk, LGPL-3.0) is a
+/// screen-reader abstraction DLL that auto-detects whichever reader is running (NVDA, JAWS, Window-Eyes,
+/// SuperNova, System Access, ZoomText) and falls back to SAPI, then routes speech to it. The native
+/// Tolk.dll plus its helpers (nvdaControllerClient64.dll, SAAPI64.dll) ship flat
+/// next to the exe (see the .csproj Content items) — Tolk.dll loads those helpers from the same folder.
+///
+/// Loaded lazily on the first and never re-attempted once it fails, so a missing DLL
+/// or absent screen reader just means silence, never an exception. All entry points are wrapped in
+/// try/catch and guarded by a lock, so it's safe to call from any thread (e.g. a hotkey callback).
+///
+internal sealed class TolkScreenReaderOutput : IScreenReaderOutput
+{
+ private readonly object sync = new();
+ private bool loadAttempted;
+ private bool loaded;
+
+ public bool Speak(string text, bool interrupt = true)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return false;
+ if (!EnsureLoaded()) return false;
+ try { return Tolk_Output(text, interrupt); }
+ catch { return false; }
+ }
+
+ public void Shutdown()
+ {
+ lock (sync)
+ {
+ if (!loaded) return;
+ try { Tolk_Unload(); } catch { /* best-effort */ }
+ loaded = false;
+ loadAttempted = false;
+ }
+ }
+
+ /// Load Tolk once. Succeeds only if Tolk loads AND reports a working speech channel, so a
+ /// machine with the DLLs present but no screen reader running stays silent instead of half-init'd.
+ private bool EnsureLoaded()
+ {
+ lock (sync)
+ {
+ if (loaded) return true;
+ if (loadAttempted) return false; // already tried and failed — don't spam load attempts
+ loadAttempted = true;
+ try { loaded = Tolk_Load() && Tolk_HasSpeech(); }
+ catch { loaded = false; }
+ return loaded;
+ }
+ }
+
+ [DllImport("Tolk.dll", CharSet = CharSet.Unicode)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool Tolk_Load();
+
+ [DllImport("Tolk.dll")]
+ private static extern void Tolk_Unload();
+
+ [DllImport("Tolk.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool Tolk_HasSpeech();
+
+ [DllImport("Tolk.dll", CharSet = CharSet.Unicode)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool Tolk_Output(string text, [MarshalAs(UnmanagedType.Bool)] bool interrupt);
+}
diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs
index 13ad086..2f6461b 100644
--- a/src/RemSound.Core/AppConfig.cs
+++ b/src/RemSound.Core/AppConfig.cs
@@ -128,6 +128,25 @@ public sealed class AppConfig
/// ticks Enable logs in the Preferences dialog.
public bool LoggingEnabled { get; set; }
+ /// If true, RemSound checks the total size of the logs\ folder at startup and,
+ /// when it exceeds megabytes, shows a one-time warning so
+ /// the user can prune or clear it. Off by default (opt-in, like the other behaviour toggles).
+ /// Machine-local — a "watch my disk on this box" decision, not a per-profile audio setting.
+ public bool WarnIfLogsFolderExceeds { get; set; }
+
+ /// The size threshold in megabytes for . Default
+ /// 100 MB. Only consulted when that flag is on.
+ public int LogsFolderWarnThresholdMb { get; set; } = 100;
+
+ /// If true, RemSound deletes log files older than days
+ /// from the logs\ folder at startup. Off by default (opt-in). The currently-open log file
+ /// is never a candidate (it's today's). Machine-local.
+ public bool PruneOldLogs { get; set; }
+
+ /// Age in days for : log files last written more than this many
+ /// days ago are deleted at startup. Range 1–30, default 14. Only consulted when that flag is on.
+ public int PruneOldLogsDays { get; set; } = 14;
+
/// If non-null and a profile with this title exists, RemSound skips the
/// startup profile picker and loads this profile directly. Combine with
/// + the Windows auto-start registry entry
diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs
index 5394918..942b19b 100644
--- a/src/RemSound.Core/Profile.cs
+++ b/src/RemSound.Core/Profile.cs
@@ -212,6 +212,9 @@ public sealed class Profile
/// Global hotkey that opens the Quick profile switch popup — a list of all profiles you
/// can arrow through and press Enter to switch to, from anywhere in Windows. Unset by default.
public HotkeyRecord? QuickProfileSwitchHotkey { get; set; }
+ /// Optional global hotkey that speaks the connection status line aloud through the active
+ /// screen reader (issue #13). Unset by default. Screen-reader specific.
+ public HotkeyRecord? SpeakStatusLineHotkey { get; set; }
/// When true, this machine honours incoming Control packets from connected
/// peers — adjusts the local volume slider or toggles mute. Default false: receiving
/// remote control is opt-in even though the audio allow-list already gates who's
diff --git a/src/RemSound.Core/RemSoundSettingsStore.cs b/src/RemSound.Core/RemSoundSettingsStore.cs
index 94ce7d3..65e37cd 100644
--- a/src/RemSound.Core/RemSoundSettingsStore.cs
+++ b/src/RemSound.Core/RemSoundSettingsStore.cs
@@ -144,6 +144,16 @@ public sealed class RemSoundSettingsStore
Save(s);
}
+ public HotkeyInfo LoadSpeakStatusLineHotkey() =>
+ Try(() => Load()?.SpeakStatusLineHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
+
+ public void SaveSpeakStatusLineHotkey(HotkeyInfo hotkey)
+ {
+ var s = Load() ?? new Settings();
+ s.SpeakStatusLineHotkey = HotkeySetting.From(hotkey);
+ Save(s);
+ }
+
public bool LoadAcceptRemoteVolumeCommands(bool defaultValue = false) =>
Try(() => Load()?.AcceptRemoteVolumeCommands) ?? defaultValue;
@@ -593,6 +603,7 @@ public sealed class RemSoundSettingsStore
SystemVolumeDownHotkey = profile.SystemVolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeDownHotkey),
SystemMuteToggleHotkey = profile.SystemMuteToggleHotkey is null ? null : HotkeySettingFromRecord(profile.SystemMuteToggleHotkey),
QuickProfileSwitchHotkey = profile.QuickProfileSwitchHotkey is null ? null : HotkeySettingFromRecord(profile.QuickProfileSwitchHotkey),
+ SpeakStatusLineHotkey = profile.SpeakStatusLineHotkey is null ? null : HotkeySettingFromRecord(profile.SpeakStatusLineHotkey),
AcceptRemoteVolumeCommands = profile.AcceptRemoteVolumeCommands,
MaxLatencyMs = profile.MaxLatencyMs,
Codec = profile.Codec,
@@ -649,6 +660,7 @@ public sealed class RemSoundSettingsStore
profile.SystemVolumeDownHotkey = s.SystemVolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeDownHotkey);
profile.SystemMuteToggleHotkey = s.SystemMuteToggleHotkey is null ? null : HotkeyRecordFromSetting(s.SystemMuteToggleHotkey);
profile.QuickProfileSwitchHotkey = s.QuickProfileSwitchHotkey is null ? null : HotkeyRecordFromSetting(s.QuickProfileSwitchHotkey);
+ profile.SpeakStatusLineHotkey = s.SpeakStatusLineHotkey is null ? null : HotkeyRecordFromSetting(s.SpeakStatusLineHotkey);
if (s.AcceptRemoteVolumeCommands is bool arvc) profile.AcceptRemoteVolumeCommands = arvc;
if (s.MaxLatencyMs is int ml) profile.MaxLatencyMs = ml;
if (s.Codec is AudioTransportCodec c) profile.Codec = c;
@@ -716,6 +728,7 @@ public sealed class RemSoundSettingsStore
public HotkeySetting? SystemVolumeDownHotkey { get; set; }
public HotkeySetting? SystemMuteToggleHotkey { get; set; }
public HotkeySetting? QuickProfileSwitchHotkey { get; set; }
+ public HotkeySetting? SpeakStatusLineHotkey { get; set; }
public bool? AcceptRemoteVolumeCommands { get; set; }
public int? MaxLatencyMs { get; set; }
public AudioTransportCodec? Codec { get; set; }
diff --git a/tolk/LICENSE-Tolk.txt b/tolk/LICENSE-Tolk.txt
new file mode 100644
index 0000000..3a3b445
--- /dev/null
+++ b/tolk/LICENSE-Tolk.txt
@@ -0,0 +1,165 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. [http://fsf.org/]
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
\ No newline at end of file
diff --git a/tolk/SAAPI64.dll b/tolk/SAAPI64.dll
new file mode 100644
index 0000000..4581825
Binary files /dev/null and b/tolk/SAAPI64.dll differ
diff --git a/tolk/Tolk.dll b/tolk/Tolk.dll
new file mode 100644
index 0000000..ceef14f
Binary files /dev/null and b/tolk/Tolk.dll differ
diff --git a/tolk/nvdaControllerClient64.dll b/tolk/nvdaControllerClient64.dll
new file mode 100644
index 0000000..6b4fff7
Binary files /dev/null and b/tolk/nvdaControllerClient64.dll differ