Bump to v1.5.0: cross-lane routing fix, recording double-tap fix, menu reorg

Bug fixes:
* BothIndependent recording was double-tapped — when both WASAPI and
  ASIO outputs were ticked, recordings came out garbled and ~2x the
  expected duration. AudioRecorder now uses four per-lane rings
  (sent-wasapi, sent-asio, recv-wasapi, recv-asio) plus a writer-
  thread mix step that drains min(wasapi, asio) frames and sum-mixes
  with the soft-tanh limiter. Tap signatures gained a RenderRoute
  parameter; Mixed maps to the wasapi slot.
* A peer announcing on the WASAPI lane was inaudible when the
  receiver only had an ASIO output ticked (and vice versa). The
  session opened, samples flowed into the SessionPlayout ring, but
  ReadForRoute(AsioLane) skipped any session whose Route was
  WasapiLane so nothing drained the ring. PlayoutEngine now tracks
  per-lane "is this lane backed by a device" via volatile bools
  settable through SetLaneActive(RenderRoute, bool), called from
  CompositeRenderBackend.SetOutputDevices whenever the device split
  changes. ReadForRoute admits orphan sessions when the other lane
  is inactive.

Menu reorganisation:
* New Options menu (Alt+O) holds Recording settings (Alt+S), Keyboard
  shortcuts (Alt+K, Ctrl+K), Startup behaviour (Alt+T), Preferences
  (Alt+P, Ctrl+P). Pre-v1.5 these were scattered across File menu,
  Record menu, and inside the Preferences dialog itself.
* Record menu mnemonic moved from Alt+O to Alt+K, rendered as
  "Record (Alt+K)" so the chord is visible despite K not being a
  letter in "Record". Alt+R is taken by Receive audio.
* File menu — new Recent profiles submenu (Alt+F, R) listing the
  five most-recently-opened profiles. Press 1..5 inside the submenu
  to jump to a slot. Missing files are skipped from the menu but
  kept in storage.
* Rename current profile moves to Alt+M (was R), Minimise to tray
  moves to Alt+N (was M).
* Lock to audio clock was Alt+K, now Alt+D.

UX additions:
* Ctrl+O = Open profile (matches the menu chord).
* New global hotkey: Start / Stop recording. Pickable from Options
  -> Keyboard shortcuts. Unbound by default. Works system-wide.

Wire format and audio pipeline unchanged from v1.4 — v1.4 and v1.5
peers interoperate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-15 17:20:01 +01:00
co-authored by Claude Opus 4.7
parent b96ba82378
commit 4915a9afe1
16 changed files with 721 additions and 218 deletions
+51
View File
@@ -20,6 +20,57 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v1.5
Menu reorganisation, multi-peer audio-routing fix, recording
fix for BothIndependent mode, and Ctrl+O for Open profile.
Wire format and audio pipeline are unchanged v1.4 and v1.5
peers interoperate.
Bug fixes:
* BothIndependent recording: when both WASAPI and ASIO
output devices were ticked, recordings came out garbled
and twice the expected duration. The recorder taps fired
from both lanes' render reads and the writer thread
appended both streams into one ring as if they were
sequential audio. Recorder now has per-lane rings and
mixes them in the writer thread.
* A peer announcing audio on the WASAPI lane was inaudible
when the receiver only had an ASIO output device ticked
(and vice versa). Sessions whose announced lane has no
active output now fall through to whichever lane IS
being read.
Menu reorganisation:
* New Options menu (Alt+O) holds: Recording settings,
Keyboard shortcuts, Startup behaviour, Preferences.
These used to be scattered across File menu (Keyboard
shortcuts, Preferences), Record menu (Recording settings),
and inside the Preferences dialog (Startup behaviour).
* Record menu mnemonic moved from Alt+O to Alt+K (rendered
as "Record (Alt+K)") so Alt+O could go to Options. K is
unusual for "Record" but the Record menu doesn't have a
natural free letter Alt+R is taken by Receive audio.
* File menu: new Recent profiles submenu (Alt+F, R). Lists
the last five profiles you've opened, most-recent first.
Press 1..5 while the submenu is open to jump to a slot.
File Rename current profile moves to Alt+F, M, and
Minimise to tray moves to Alt+F, N, to free up R for the
new submenu.
* Lock to audio clock (Audio profile tab) was Alt+K; now
Alt+D (the D in "audio") since the Record menu won Alt+K.
UX additions:
* Ctrl+O opens the Open profile dialog (matches the menu
chord). Previously the menu had no global shortcut.
* New global hotkey: Start / Stop recording. Pickable from
Options - Keyboard shortcuts. Unbound by default. Works
system-wide RemSound doesn't need keyboard focus.
Recording feature unchanged in this release the dialog
layout, formats (WAV / MP3 / OGG-Opus / FLAC), and tap-points
all the same as v1.4.
RemSound v1.4
Recording-settings dialog cleanup and a few mnemonic
+180 -48
View File
@@ -76,17 +76,41 @@ internal sealed class AudioRecorder : IDisposable
private readonly Action<string>? onDiagnostic;
private readonly Action<string, long>? onFinished;
// === Lock-free SPSC rings, one per direction ===
// === Lock-free SPSC rings, per direction × per lane ===
// Write head is monotonically increasing (NOT wrapped). Ring index = head % capacity.
// This avoids the ABA problem on wraparound and means the audio thread only needs an
// atomic add (not a CAS) to publish a write. The writer thread holds the read head
// (no atomic needed; single consumer).
private readonly float[] sentRing = new float[RingCapacityFloats];
private readonly float[] receivedRing = new float[RingCapacityFloats];
private long sentWriteHead; // updated atomically from audio thread
private long sentReadHead; // owned by writer thread
private long receivedWriteHead; // updated atomically from audio thread
private long receivedReadHead; // owned by writer thread
//
// Four rings rather than two so the writer can correctly handle BothIndependent mode
// where the PlayoutEngine's per-lane Read fires from BOTH the WASAPI lane and the ASIO
// lane independently. Pre-2026-05-15 the recorder had a single ring per direction and
// both lanes' samples got appended sequentially — the file ended up with twice the
// expected audio at half the wall-clock duration, garbled because the two lanes' content
// was different.
//
// Lane mapping:
// * RenderRoute.WasapiLane → wasapi slot
// * RenderRoute.AsioLane → asio slot
// * RenderRoute.Mixed → wasapi slot (classic modes have only one tap firing, so
// the asio slot stays empty — no double-up)
//
// The writer thread reads from both slots per direction and:
// * mixes them when both have data (BothIndependent mode with both output lanes active),
// * drains whichever solo lane has data when only one is firing (classic modes, or
// BothIndependent with only one lane's output ticked).
private readonly float[] sentWasapiRing = new float[RingCapacityFloats];
private readonly float[] sentAsioRing = new float[RingCapacityFloats];
private readonly float[] receivedWasapiRing = new float[RingCapacityFloats];
private readonly float[] receivedAsioRing = new float[RingCapacityFloats];
private long sentWasapiWriteHead;
private long sentWasapiReadHead;
private long sentAsioWriteHead;
private long sentAsioReadHead;
private long receivedWasapiWriteHead;
private long receivedWasapiReadHead;
private long receivedAsioWriteHead;
private long receivedAsioReadHead;
private long droppedSampleFrames;
// Wake-up event. Audio threads Set after appending to a ring; writer thread Waits.
@@ -146,23 +170,44 @@ internal sealed class AudioRecorder : IDisposable
// === Audio-thread side: bounded to a memcpy + atomic add + event-set ===
/// <summary>Tap target for sender-side audio. Discarded silently if this recorder's
/// source mode is "received only". Lock-free, allocation-free; safe to call from
/// the audio thread.</summary>
public void WriteSent(ReadOnlyMemory<float> stereoFloats)
/// source mode is "received only". The <paramref name="lane"/> identifies which
/// SenderLane the samples came from so the writer thread can keep WASAPI-lane and
/// ASIO-lane streams separate (and mix them at drain time). RenderRoute.Mixed (the
/// classic-mode case) routes to the WASAPI slot as the canonical "single lane".
/// Lock-free, allocation-free; safe to call from the audio thread.</summary>
public void WriteSent(ReadOnlyMemory<float> stereoFloats, RenderRoute lane)
{
if (stopped) return;
if (settings.Source == RecordingSource.ReceivedOnly) return;
AppendToRing(stereoFloats.Span, sentRing, ref sentWriteHead, ref sentReadHead);
if (lane == RenderRoute.AsioLane)
{
AppendToRing(stereoFloats.Span, sentAsioRing, ref sentAsioWriteHead, ref sentAsioReadHead);
}
else
{
// WasapiLane and Mixed both land in the wasapi slot. In classic modes only
// this slot fires; in BothIndependent the WASAPI lane fires here and the ASIO
// lane fires in the asio slot above.
AppendToRing(stereoFloats.Span, sentWasapiRing, ref sentWasapiWriteHead, ref sentWasapiReadHead);
}
}
/// <summary>Tap target for receiver-side audio. Discarded silently if this recorder's
/// source mode is "sent only". Lock-free, allocation-free; safe to call from the
/// render thread.</summary>
public void WriteReceived(ReadOnlyMemory<float> stereoFloats)
/// source mode is "sent only". <paramref name="lane"/> tags which PlayoutEngine
/// per-lane Read invoked us — same RenderRoute mapping as <see cref="WriteSent"/>.
/// Lock-free, allocation-free; safe to call from the render thread.</summary>
public void WriteReceived(ReadOnlyMemory<float> stereoFloats, RenderRoute lane)
{
if (stopped) return;
if (settings.Source == RecordingSource.SentOnly) return;
AppendToRing(stereoFloats.Span, receivedRing, ref receivedWriteHead, ref receivedReadHead);
if (lane == RenderRoute.AsioLane)
{
AppendToRing(stereoFloats.Span, receivedAsioRing, ref receivedAsioWriteHead, ref receivedAsioReadHead);
}
else
{
AppendToRing(stereoFloats.Span, receivedWasapiRing, ref receivedWasapiWriteHead, ref receivedWasapiReadHead);
}
}
/// <summary>Lock-free, allocation-free append to a single-producer-single-consumer
@@ -243,8 +288,15 @@ internal sealed class AudioRecorder : IDisposable
private bool HasEnoughData(int minFrames = DrainChunkFrames)
{
var sentAvail = (Volatile.Read(ref sentWriteHead) - sentReadHead) / MixChannels;
var recvAvail = (Volatile.Read(ref receivedWriteHead) - receivedReadHead) / MixChannels;
var sentWasapi = (Volatile.Read(ref sentWasapiWriteHead) - sentWasapiReadHead) / MixChannels;
var sentAsio = (Volatile.Read(ref sentAsioWriteHead) - sentAsioReadHead) / MixChannels;
var recvWasapi = (Volatile.Read(ref receivedWasapiWriteHead) - receivedWasapiReadHead) / MixChannels;
var recvAsio = (Volatile.Read(ref receivedAsioWriteHead) - receivedAsioReadHead) / MixChannels;
// "Any frame in this direction" check — the per-lane drain helper handles the
// mix-vs-solo decision at process time, so for the wakeup heuristic we just need to
// know SOMETHING is waiting in the direction(s) we care about.
var sentAvail = sentWasapi + sentAsio;
var recvAvail = recvWasapi + recvAsio;
return settings.Source switch
{
RecordingSource.SentOnly => sentAvail >= minFrames,
@@ -254,47 +306,114 @@ internal sealed class AudioRecorder : IDisposable
};
}
/// <summary>Drain one direction worth of audio into <paramref name="dst"/>, merging the
/// WASAPI-lane and ASIO-lane rings into a single stream. Behaviour:
/// * Both lanes have frames available: drain <c>min(wasapi, asio, maxFrames)</c>,
/// sum-mix with a soft-tanh limiter on the sum (same pattern as the cross-direction
/// "Both" mode mix downstream).
/// * Only one lane has frames: drain it solo into dst (the inactive lane contributes
/// nothing this tick).
/// * Neither lane has frames: return 0; caller skips this direction.
/// Returns the number of stereo frames written into dst.
///
/// The <paramref name="aux"/> span must be at least dst.Length floats; it's used as the
/// staging area for the second lane during a both-lane mix and is otherwise unused.</summary>
private static int DrainOneDirection(
float[] wasapiRing, ref long wasapiWriteHead, ref long wasapiReadHead,
float[] asioRing, ref long asioWriteHead, ref long asioReadHead,
Span<float> dst, Span<float> aux, int maxFrames)
{
var wasapiAvail = (int)((Volatile.Read(ref wasapiWriteHead) - wasapiReadHead) / MixChannels);
var asioAvail = (int)((Volatile.Read(ref asioWriteHead) - asioReadHead) / MixChannels);
if (wasapiAvail > 0 && asioAvail > 0)
{
var frames = Math.Min(Math.Min(wasapiAvail, asioAvail), maxFrames);
if (frames <= 0) return 0;
var len = frames * MixChannels;
CopyFromRing(wasapiRing, ref wasapiReadHead, dst.Slice(0, len));
CopyFromRing(asioRing, ref asioReadHead, aux.Slice(0, len));
// Sum + soft-tanh limit. Two BothIndependent lanes routinely carry different
// content (each lane is its own peer-stream selection), so summing is the right
// mix; the limiter prevents two simultaneously-hot lanes from clipping the file.
for (var i = 0; i < len; i++)
{
var s = dst[i] + aux[i];
if (s > 1f) s = 1f - MathF.Tanh(s - 1f);
else if (s < -1f) s = -1f + MathF.Tanh(-1f - s);
dst[i] = s;
}
return frames;
}
if (wasapiAvail > 0)
{
var frames = Math.Min(wasapiAvail, maxFrames);
if (frames <= 0) return 0;
CopyFromRing(wasapiRing, ref wasapiReadHead, dst.Slice(0, frames * MixChannels));
return frames;
}
if (asioAvail > 0)
{
var frames = Math.Min(asioAvail, maxFrames);
if (frames <= 0) return 0;
CopyFromRing(asioRing, ref asioReadHead, dst.Slice(0, frames * MixChannels));
return frames;
}
return 0;
}
private void Process()
{
var sentAvailFrames = (int)((Volatile.Read(ref sentWriteHead) - sentReadHead) / MixChannels);
var recvAvailFrames = (int)((Volatile.Read(ref receivedWriteHead) - receivedReadHead) / MixChannels);
int framesThisCall;
switch (settings.Source)
{
case RecordingSource.SentOnly:
framesThisCall = Math.Min(sentAvailFrames, DrainChunkMaxFrames);
// One-shot scratch sizing — start big enough for the chunk cap so we don't
// resize per call. The actual write may be smaller depending on per-lane
// availability.
EnsureScratchSize(DrainChunkMaxFrames * MixChannels);
EnsureSecondaryScratchSize(DrainChunkMaxFrames * MixChannels);
framesThisCall = DrainOneDirection(
sentWasapiRing, ref sentWasapiWriteHead, ref sentWasapiReadHead,
sentAsioRing, ref sentAsioWriteHead, ref sentAsioReadHead,
mixScratch, mixScratchAux, DrainChunkMaxFrames);
if (framesThisCall <= 0) return;
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(sentRing, ref sentReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
EmitMixBuffer(framesThisCall);
break;
case RecordingSource.ReceivedOnly:
framesThisCall = Math.Min(recvAvailFrames, DrainChunkMaxFrames);
EnsureScratchSize(DrainChunkMaxFrames * MixChannels);
EnsureSecondaryScratchSize(DrainChunkMaxFrames * MixChannels);
framesThisCall = DrainOneDirection(
receivedWasapiRing, ref receivedWasapiWriteHead, ref receivedWasapiReadHead,
receivedAsioRing, ref receivedAsioWriteHead, ref receivedAsioReadHead,
mixScratch, mixScratchAux, DrainChunkMaxFrames);
if (framesThisCall <= 0) return;
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(receivedRing, ref receivedReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
EmitMixBuffer(framesThisCall);
break;
case RecordingSource.Both:
// Mix the two sides. Drain min(sent, received) frames so both sides
// advance together. If one side has zero (e.g. peer disconnected, or
// local capture is off), drain the other side alone — treat the silent
// side as zero for those frames. This prevents permanent stalls in
// "Both" mode when one direction has no traffic.
if (sentAvailFrames > 0 && recvAvailFrames > 0)
// Two-stage drain. First produce a per-direction stream for each direction
// (lane-mixed if both lanes have data), then sum-mix the two directions just
// like the pre-2026-05-15 Both path did. The lane mix uses mixScratchAux as
// its workspace; the cross-direction mix uses mixScratch (sent) + a per-call
// received scratch we'll grow as needed.
EnsureScratchSize(DrainChunkMaxFrames * MixChannels);
EnsureSecondaryScratchSize(DrainChunkMaxFrames * MixChannels);
var sentFrames = DrainOneDirection(
sentWasapiRing, ref sentWasapiWriteHead, ref sentWasapiReadHead,
sentAsioRing, ref sentAsioWriteHead, ref sentAsioReadHead,
mixScratch, mixScratchAux, DrainChunkMaxFrames);
EnsureRecvDirectionScratchSize(DrainChunkMaxFrames * MixChannels);
var recvFrames = DrainOneDirection(
receivedWasapiRing, ref receivedWasapiWriteHead, ref receivedWasapiReadHead,
receivedAsioRing, ref receivedAsioWriteHead, ref receivedAsioReadHead,
recvDirectionScratch, mixScratchAux, DrainChunkMaxFrames);
if (sentFrames > 0 && recvFrames > 0)
{
framesThisCall = Math.Min(Math.Min(sentAvailFrames, recvAvailFrames), DrainChunkMaxFrames);
EnsureScratchSize(framesThisCall * MixChannels);
EnsureSecondaryScratchSize(framesThisCall * MixChannels);
framesThisCall = Math.Min(sentFrames, recvFrames);
var dst = mixScratch.AsSpan(0, framesThisCall * MixChannels);
var aux = mixScratchAux.AsSpan(0, framesThisCall * MixChannels);
CopyFromRing(sentRing, ref sentReadHead, dst);
CopyFromRing(receivedRing, ref receivedReadHead, aux);
// Sum-mix. Soft-tanh limiter on the sum keeps two simultaneously
// hot inputs from clipping.
var aux = recvDirectionScratch.AsSpan(0, framesThisCall * MixChannels);
for (var i = 0; i < dst.Length; i++)
{
var s = dst[i] + aux[i];
@@ -302,18 +421,22 @@ internal sealed class AudioRecorder : IDisposable
else if (s < -1f) s = -1f + MathF.Tanh(-1f - s);
dst[i] = s;
}
// Any leftover frames in the direction that produced MORE this tick stay
// in their rings for the next iteration — they're not lost, just deferred.
// We can't write them now without un-syncing the two directions.
}
else if (sentAvailFrames > 0)
else if (sentFrames > 0)
{
framesThisCall = Math.Min(sentAvailFrames, DrainChunkMaxFrames);
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(sentRing, ref sentReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
framesThisCall = sentFrames;
// mixScratch already contains the sent direction's audio — emit as-is.
}
else if (recvAvailFrames > 0)
else if (recvFrames > 0)
{
framesThisCall = Math.Min(recvAvailFrames, DrainChunkMaxFrames);
EnsureScratchSize(framesThisCall * MixChannels);
CopyFromRing(receivedRing, ref receivedReadHead, mixScratch.AsSpan(0, framesThisCall * MixChannels));
framesThisCall = recvFrames;
// The recv-direction audio lives in recvDirectionScratch; copy into
// mixScratch so EmitMixBuffer (which reads from mixScratch) sees it.
var len = framesThisCall * MixChannels;
recvDirectionScratch.AsSpan(0, len).CopyTo(mixScratch.AsSpan(0, len));
}
else
{
@@ -376,6 +499,11 @@ internal sealed class AudioRecorder : IDisposable
if (mixScratchAux.Length < floats) mixScratchAux = new float[floats];
}
private void EnsureRecvDirectionScratchSize(int floats)
{
if (recvDirectionScratch.Length < floats) recvDirectionScratch = new float[floats];
}
private void EnsureMonoScratchSize(int frames)
{
if (monoScratch.Length < frames) monoScratch = new float[frames];
@@ -431,6 +559,10 @@ internal sealed class AudioRecorder : IDisposable
private IFormatWriter? formatWriter;
private float[] mixScratch = new float[DrainChunkFrames * MixChannels];
private float[] mixScratchAux = new float[DrainChunkFrames * MixChannels];
// Holds the per-direction "received" mix during a Both-source iteration, kept separate
// from mixScratch (which holds "sent") so the cross-direction final mix can read both
// simultaneously without one stomping the other.
private float[] recvDirectionScratch = new float[DrainChunkFrames * MixChannels];
private float[] monoScratch = new float[DrainChunkFrames];
private static string ExtensionFor(RecordingFileFormat format) => format switch
+201 -50
View File
@@ -42,6 +42,9 @@ public sealed class MainForm : Form
// the visible text + accessibility name between "Start recording" and "Stop recording"
// without rebuilding the menu.
private ToolStripMenuItem? startStopRecordingMenuItem;
// 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;
// --- Main form controls ---
// Two standalone CheckBoxes for the Send / Receive toggles. Modern .NET (.NET 10) raises
@@ -449,6 +452,19 @@ public sealed class MainForm : Form
{
currentProfilePath = profileStore.PathFor(loadedTitle);
}
// Track the loaded profile in the machine-local recents list so the File → Recent
// profiles submenu can offer it next time. Skipped for the blank-template case
// (currentProfilePath stays null when no profile was loaded). 2026-05-15.
if (!string.IsNullOrEmpty(currentProfilePath))
{
try
{
var cfg = AppConfig.Load();
cfg.NoteRecentProfile(currentProfilePath);
cfg.Save();
}
catch { /* benign — recents tracking is a convenience, not load-critical */ }
}
pendingProfile = profile;
// Push the profile's settings-shaped fields (codec, hotkeys, smoothness, etc.) into
// the in-memory settings cache BEFORE the rest of the constructor body reads from it.
@@ -490,6 +506,10 @@ public sealed class MainForm : Form
ToggleTrayFromHotkey,
() => NudgeVolume(+5),
() => NudgeVolume(-5),
// Global Start / Stop recording. Same ToggleRecording path the Record menu item
// and the in-app Ctrl+R use — the hotkey just makes it work without RemSound
// having keyboard focus.
ToggleRecording,
// Three remote-control hotkeys: each one transmits a Control packet to all
// currently-tracked peers via the audio sender's NAT pinhole. The receiving peer
// applies the change locally if it has Profile.AcceptRemoteVolumeCommands on.
@@ -1018,10 +1038,24 @@ public sealed class MainForm : Form
var openItem = new ToolStripMenuItem("&Open profile...")
{
ShortcutKeys = Keys.Control | Keys.O,
AccessibleName = "Open profile",
};
openItem.Click += (_, _) => OpenProfileFromPicker();
// Recent profiles submenu. Populated dynamically on drop-down so the latest list is
// always shown — AppConfig.RecentProfiles is the source of truth and gets mutated on
// every profile load. Each item gets a 1..5 single-digit mnemonic so the user can
// pick a recent without having to read it: Alt+F, R, 1 jumps to the most recent;
// Alt+F, R, 2 to the second-most-recent, etc.
recentProfilesMenu = new ToolStripMenuItem("&Recent profiles")
{
AccessibleName = "Recent profiles",
};
recentProfilesMenu.DropDownOpening += (_, _) => PopulateRecentProfilesMenu();
// Seed the submenu so it isn't visibly empty before the first DropDownOpening fires.
PopulateRecentProfilesMenu();
var saveItem = new ToolStripMenuItem("&Save")
{
ShortcutKeys = Keys.Control | Keys.S,
@@ -1035,36 +1069,24 @@ public sealed class MainForm : Form
};
saveAsItem.Click += (_, _) => SaveProfileAs();
var renameItem = new ToolStripMenuItem("&Rename current profile...")
var renameItem = new ToolStripMenuItem("Rena&me current profile...")
{
AccessibleName = "Rename current profile",
};
renameItem.Click += (_, _) => RenameCurrentProfile();
var minimiseItem = new ToolStripMenuItem("&Minimise to tray")
var minimiseItem = new ToolStripMenuItem("Mi&nimise to tray")
{
// No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → M) plus the
// configurable "Show or hide window" hotkey cover this. Pre-2026-05-11 Alt+M was
// gated per-tab via ProcessCmdKey because the Audio I/O tab had an "Audio mode"
// listbox that used Alt+M; that listbox is gone now so the gating was retired.
// No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → N now —
// moved off M because the Rename item took the M slot in the 2026-05-15 menu
// reorg) plus the configurable "Show or hide window" hotkey cover this. Pre-
// 2026-05-11 Alt+M was gated per-tab via ProcessCmdKey because the Audio I/O
// tab had an "Audio mode" listbox that used Alt+M; that listbox is gone now
// so the gating was retired.
AccessibleName = "Minimise to tray",
};
minimiseItem.Click += (_, _) => trayController.Minimize();
var keyboardItem = new ToolStripMenuItem("&Keyboard shortcuts...")
{
ShortcutKeys = Keys.Control | Keys.K,
AccessibleName = "Keyboard shortcuts",
};
keyboardItem.Click += (_, _) => hotkeyController.ShowKeyboardShortcutsDialog(this);
var prefsItem = new ToolStripMenuItem("&Preferences...")
{
ShortcutKeys = Keys.Control | Keys.P,
AccessibleName = "Preferences",
};
prefsItem.Click += (_, _) => OpenPreferencesDialog();
var exitItem = new ToolStripMenuItem("E&xit")
{
AccessibleName = "Exit RemSound",
@@ -1074,17 +1096,69 @@ public sealed class MainForm : Form
fileMenu.DropDownItems.AddRange(new ToolStripItem[]
{
openItem,
recentProfilesMenu,
saveItem,
saveAsItem,
renameItem,
new ToolStripSeparator(),
minimiseItem,
keyboardItem,
prefsItem,
new ToolStripSeparator(),
exitItem,
});
// === Options menu (new, 2026-05-15) ===
// Holds all the "configure the app" entry points that used to be scattered across
// the File menu (Keyboard shortcuts, Preferences) and the Record menu (Recording
// settings). Startup behaviour is also here as its own top-level item rather than
// hiding inside Preferences as it did before. Reads as a natural sequence:
// recording-specific → input config → startup → general prefs.
//
// Mnemonic Alt+O — natural for "Options". Required moving the Record menu off of
// Alt+O (it's now Alt+K — see comment in BuildRecordMenu); the trade reads more
// naturally for users because "Options" is exactly what's in the menu.
var optionsMenu = new ToolStripMenuItem("&Options") { AccessibleName = "Options menu" };
var recordingSettingsItem = new ToolStripMenuItem("Recording &settings...")
{
AccessibleName = "Recording settings",
};
recordingSettingsItem.Click += (_, _) => OpenRecordingSettingsDialog();
var keyboardItem = new ToolStripMenuItem("&Keyboard shortcuts...")
{
ShortcutKeys = Keys.Control | Keys.K,
AccessibleName = "Keyboard shortcuts",
};
keyboardItem.Click += (_, _) => hotkeyController.ShowKeyboardShortcutsDialog(this);
var startupBehaviourItem = new ToolStripMenuItem("S&tartup behaviour...")
{
AccessibleName = "Startup behaviour",
};
startupBehaviourItem.Click += (_, _) =>
{
using var dialog = new StartupBehaviourDialog(profileStore);
dialog.ShowDialog(this);
// Startup-behaviour state persists through AppConfig / registry directly. No
// profile-dirty flag involved here — none of these settings live on Profile.
};
var prefsItem = new ToolStripMenuItem("&Preferences...")
{
ShortcutKeys = Keys.Control | Keys.P,
AccessibleName = "Preferences",
};
prefsItem.Click += (_, _) => OpenPreferencesDialog();
optionsMenu.DropDownItems.AddRange(new ToolStripItem[]
{
recordingSettingsItem,
keyboardItem,
startupBehaviourItem,
new ToolStripSeparator(),
prefsItem,
});
// Help menu — separate from File so users with their hand on Alt + arrow keys can
// walk straight to it. F1 is the global "open the manual" key; the menu mirrors it
// for users who prefer mouse / arrow navigation.
@@ -1120,12 +1194,91 @@ public sealed class MainForm : Form
var recordMenu = BuildRecordMenu();
// Order: File / Record / Options / Help. Options sits between Record and Help per
// user request — left-to-right reads file-management → recording-tasks → config →
// help, which is the natural sequence for someone walking the menu bar with Alt
// and the arrow keys.
menu.Items.Add(fileMenu);
menu.Items.Add(recordMenu);
menu.Items.Add(optionsMenu);
menu.Items.Add(helpMenu);
return menu;
}
/// <summary>Rebuild the Recent profiles submenu from <see cref="AppConfig.RecentProfiles"/>.
/// Called once during menu construction (so it's not visibly empty before the first
/// open) and on every DropDownOpening so the latest list is always shown. Entries that
/// reference a profile file that no longer exists on disk are skipped — the path stays
/// in the AppConfig list (it might come back, e.g. external drive remount) but doesn't
/// clutter the menu.
///
/// Mnemonic / numeric-pick convention: each item is prefixed with "&N" where N is 1..5
/// for the position. Pressing the digit while the submenu is open selects that item.
/// The most-recently-opened profile is &1 (top); oldest in the list is &5 (bottom).</summary>
private void PopulateRecentProfilesMenu()
{
if (recentProfilesMenu is null) return;
recentProfilesMenu.DropDownItems.Clear();
var cfg = AppConfig.Load();
var slot = 1;
foreach (var path in cfg.RecentProfiles)
{
if (string.IsNullOrWhiteSpace(path)) continue;
if (!File.Exists(path)) continue; // skip missing files; keep in storage in case they reappear
var title = Path.GetFileNameWithoutExtension(path);
var item = new ToolStripMenuItem($"&{slot} {title}")
{
AccessibleName = $"Recent profile {slot}: {title}",
// Stash the path on the menu item so the click handler doesn't depend on
// closure capture of the loop variable.
Tag = path,
};
item.Click += (s, _) =>
{
var sender = (ToolStripMenuItem)s!;
var profilePath = (string)sender.Tag!;
SwitchToRecentProfile(profilePath);
};
recentProfilesMenu.DropDownItems.Add(item);
slot++;
if (slot > AppConfig.MaxRecentProfiles) break;
}
if (recentProfilesMenu.DropDownItems.Count == 0)
{
recentProfilesMenu.DropDownItems.Add(new ToolStripMenuItem("(No recent profiles)")
{
Enabled = false,
AccessibleName = "No recent profiles",
});
}
}
/// <summary>Switch to the profile at <paramref name="path"/> via the same close-and-relaunch
/// flow OpenProfileFromPicker uses. The active profile gets pushed to the front of the
/// recents list by the next MainForm constructor when it sees the loaded path.</summary>
private void SwitchToRecentProfile(string path)
{
if (string.IsNullOrWhiteSpace(path)) return;
if (string.Equals(path, currentProfilePath, StringComparison.OrdinalIgnoreCase)) return; // already loaded
if (!File.Exists(path))
{
MessageBox.Show(this,
$"Profile file no longer exists:\n\n{path}\n\nIt'll be removed from the Recent profiles list.",
"Recent profile", MessageBoxButtons.OK, MessageBoxIcon.Information);
// Trim the dead entry out of the recents list so the user doesn't keep seeing it.
var cfg = AppConfig.Load();
cfg.RecentProfiles.RemoveAll(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase));
try { cfg.Save(); } catch { /* benign — list will be re-pruned at next attempt */ }
return;
}
var title = Path.GetFileNameWithoutExtension(path);
if (string.IsNullOrEmpty(title)) return;
NextProfilePathToLoad = path;
NextProfileTitleToLoad = title;
AppendLogEntry($"profile switch via Recent profiles: \"{title}\" from {path}");
Close();
}
/// <summary>Build the Record menu — Start/stop recording (toggling label), recording
/// settings dialog, open the configured folder, and change the configured folder.
/// Ctrl+R is the global toggle so the user can start/stop without going through the
@@ -1133,11 +1286,17 @@ public sealed class MainForm : Form
/// inside the sub-dialog because both live on the profile.</summary>
private ToolStripMenuItem BuildRecordMenu()
{
// Record menu uses Alt+O (Rec&ord) rather than Alt+R. The form's "Receive audio
// (Alt+R)" checkbox lives on the main canvas alongside the menu bar and Alt+R was
// ambiguous between the two. Alt+O is unused elsewhere on the menu bar (File / Help
// / Record) and reads as "Recording" naturally enough for the mnemonic to stick.
var recordMenu = new ToolStripMenuItem("Rec&ord") { AccessibleName = "Record menu" };
// Record menu uses Alt+K. The natural "R" letter is taken on the main form by the
// Receive audio checkbox; "O" is now claimed by the Options menu (2026-05-15
// reorg). K isn't a letter in "Record", so we surface the mnemonic explicitly in
// the title: "Record (Alt+K)" with the K underlined. The visible hint keeps the
// chord discoverable for keyboard-only users despite the unusual letter choice.
//
// This collides with the Lock-to-audio-clock checkbox on the Audio profile tab
// which used to take Alt+K — the menu always wins at the form's top level, so the
// checkbox loses its mnemonic and stays Tab-reachable only. The (Alt+&K) hint on
// that checkbox's text was removed below to avoid a misleading prompt.
var recordMenu = new ToolStripMenuItem("Record (Alt+&K)") { AccessibleName = "Record menu" };
// Start/Stop uses Alt+R — matches the Ctrl+R global toggle so the same letter does
// the same job from either entry point. The "&" position shifts when the label flips
@@ -1150,14 +1309,6 @@ public sealed class MainForm : Form
};
startStopRecordingMenuItem.Click += (_, _) => ToggleRecording();
// Recording settings → Alt+S (was Alt+T). S reads more naturally than T for
// "settings", and the slot freed up when Start/Stop moved off Alt+S.
var settingsItem = new ToolStripMenuItem("Recording &settings...")
{
AccessibleName = "Recording settings",
};
settingsItem.Click += (_, _) => OpenRecordingSettingsDialog();
var openFolderItem = new ToolStripMenuItem("&Open current recordings folder")
{
AccessibleName = "Open current recordings folder",
@@ -1173,12 +1324,15 @@ public sealed class MainForm : Form
if (recordingController.ChangeFolder(this)) MarkProfileDirty();
};
// Recording settings used to live here as the third item with Alt+S; in the
// 2026-05-15 menu reorg it moved out to the Options menu so all of the "configure
// the app" affordances live together. The Record menu now only carries the start /
// stop toggle plus the two folder operations — actions you perform AT recording
// time, not configuration.
recordMenu.DropDownItems.AddRange(new ToolStripItem[]
{
startStopRecordingMenuItem,
new ToolStripSeparator(),
settingsItem,
new ToolStripSeparator(),
openFolderItem,
changeFolderItem,
});
@@ -1822,25 +1976,22 @@ public sealed class MainForm : Form
panel.Controls.Add(codecRowPanel, 1, 0);
// === Row 1: Tight latency (sender-side, mode-dependent label) ===
// Mnemonic moved from G to K (2026-05-08). The label varies per current audio mode
// but every variant starts with "Lock to audio clock" — putting "&k" in "Loc&k" gives
// the user a stable Alt+K regardless of which mode-dependent suffix is shown.
// Only WasapiOnly and BothIndependent are reachable through the UI after the
// 2026-05-11 cleanup (an ASIO driver is either selected or it isn't); the AsioOnly /
// classic-Both branches survive only to make pre-2026-05-11 profile JSONs that hold
// those enum values render with sensible labels until the user nudges the driver.
// Mnemonic was Alt+K until v1.5 (2026-05-15) when the Record menu took Alt+K at
// the menu-bar level. Replaced with Alt+D — the D in "au&dio" is naturally part of
// the word, no explicit "(Alt+...)" hint needed. Free on the Audio profile tab
// (no other Audio-profile control uses D).
var currentAudioModeForLabel = settings.LoadAudioMode();
var tightLatencyText = currentAudioModeForLabel switch
{
AudioMode.WasapiOnly => "Lock to audio clock, WASAPI sender (Alt+&K)",
AudioMode.BothIndependent => "Lock to audio clock, WASAPI + ASIO senders (Alt+&K)",
_ => "Lock to audio clock (Alt+&K)",
AudioMode.WasapiOnly => "Lock to au&dio clock, WASAPI sender",
AudioMode.BothIndependent => "Lock to au&dio clock, WASAPI + ASIO senders",
_ => "Lock to au&dio clock",
};
var tightLatencyAccessible = currentAudioModeForLabel switch
{
AudioMode.WasapiOnly => "Lock to audio clock (Alt+K) — sender uses the WASAPI capture event for timing instead of a Stopwatch tick. Tightens delay; brief clicks possible if the link can't keep up.",
AudioMode.BothIndependent => "Lock to audio clock (Alt+K) — both lanes tighten independently. WASAPI lane uses push-mode (single source); ASIO lane emits per callback. Brief clicks possible on either if the link can't keep up.",
_ => "Lock to audio clock (Alt+K) — sender-side timing tighten.",
AudioMode.WasapiOnly => "Lock to audio clock (Alt+D) — sender uses the WASAPI capture event for timing instead of a Stopwatch tick. Tightens delay; brief clicks possible if the link can't keep up.",
AudioMode.BothIndependent => "Lock to audio clock (Alt+D) — both lanes tighten independently. WASAPI lane uses push-mode (single source); ASIO lane emits per callback. Brief clicks possible on either if the link can't keep up.",
_ => "Lock to audio clock (Alt+D) — sender-side timing tighten.",
};
tightLatencyBox.Text = tightLatencyText;
tightLatencyBox.AccessibleName = tightLatencyAccessible;
+46 -12
View File
@@ -11,6 +11,11 @@ internal sealed class MainFormHotkeyController : IDisposable
private readonly Action toggleTray;
private readonly Action volumeUp;
private readonly Action volumeDown;
// Toggle recording — fires the same ToggleRecording path the Record menu item and the
// in-app Ctrl+R shortcut hit. The in-app Ctrl+R only fires when MainForm has focus; this
// hotkey works system-wide via RegisterHotKey. Default binding is unset so we don't
// collide with anything on a fresh install — users who want it pick their own combo.
private readonly Action toggleRecording;
// Remote control hotkeys: trigger this machine to send a Control packet to its connected
// peers. The local volume slider on this machine isn't touched — receivers that have opted
// in handle the change. See Profile.AcceptRemoteVolumeCommands and the RemPacketType.Control
@@ -31,6 +36,7 @@ internal sealed class MainFormHotkeyController : IDisposable
private HotkeyInfo trayHotkey;
private HotkeyInfo volumeUpHotkey;
private HotkeyInfo volumeDownHotkey;
private HotkeyInfo toggleRecordingHotkey;
private HotkeyInfo remoteVolumeUpHotkey;
private HotkeyInfo remoteVolumeDownHotkey;
private HotkeyInfo remoteMuteToggleHotkey;
@@ -42,6 +48,7 @@ internal sealed class MainFormHotkeyController : IDisposable
private GlobalHotkey? trayGlobalHotkey;
private GlobalHotkey? volumeUpGlobalHotkey;
private GlobalHotkey? volumeDownGlobalHotkey;
private GlobalHotkey? toggleRecordingGlobalHotkey;
private GlobalHotkey? remoteVolumeUpGlobalHotkey;
private GlobalHotkey? remoteVolumeDownGlobalHotkey;
private GlobalHotkey? remoteMuteToggleGlobalHotkey;
@@ -70,6 +77,7 @@ internal sealed class MainFormHotkeyController : IDisposable
Action toggleTray,
Action volumeUp,
Action volumeDown,
Action toggleRecording,
Action sendRemoteVolumeUp,
Action sendRemoteVolumeDown,
Action sendRemoteMuteToggle,
@@ -83,6 +91,7 @@ internal sealed class MainFormHotkeyController : IDisposable
this.toggleTray = toggleTray;
this.volumeUp = volumeUp;
this.volumeDown = volumeDown;
this.toggleRecording = toggleRecording;
this.sendRemoteVolumeUp = sendRemoteVolumeUp;
this.sendRemoteVolumeDown = sendRemoteVolumeDown;
this.sendRemoteMuteToggle = sendRemoteMuteToggle;
@@ -94,6 +103,7 @@ internal sealed class MainFormHotkeyController : IDisposable
trayHotkey = settingsStore.LoadTrayHotkey();
volumeUpHotkey = settingsStore.LoadVolumeUpHotkey();
volumeDownHotkey = settingsStore.LoadVolumeDownHotkey();
toggleRecordingHotkey = settingsStore.LoadToggleRecordingHotkey();
remoteVolumeUpHotkey = settingsStore.LoadRemoteVolumeUpHotkey();
remoteVolumeDownHotkey = settingsStore.LoadRemoteVolumeDownHotkey();
remoteMuteToggleHotkey = settingsStore.LoadRemoteMuteToggleHotkey();
@@ -110,6 +120,7 @@ internal sealed class MainFormHotkeyController : IDisposable
trayGlobalHotkey = new GlobalHotkey(ownerForm);
volumeUpGlobalHotkey = new GlobalHotkey(ownerForm);
volumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
toggleRecordingGlobalHotkey = new GlobalHotkey(ownerForm);
remoteVolumeUpGlobalHotkey = new GlobalHotkey(ownerForm);
remoteVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
remoteMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm);
@@ -121,6 +132,7 @@ internal sealed class MainFormHotkeyController : IDisposable
trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray);
volumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(volumeUp);
volumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(volumeDown);
toggleRecordingGlobalHotkey.Pressed += () => InvokeOnOwner(toggleRecording);
remoteVolumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteVolumeUp);
remoteVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteVolumeDown);
remoteMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteMuteToggle);
@@ -132,6 +144,7 @@ internal sealed class MainFormHotkeyController : IDisposable
RegisterTrayHotkey();
RegisterVolumeUpHotkey();
RegisterVolumeDownHotkey();
RegisterToggleRecordingHotkey();
RegisterRemoteVolumeUpHotkey();
RegisterRemoteVolumeDownHotkey();
RegisterRemoteMuteToggleHotkey();
@@ -229,11 +242,17 @@ internal sealed class MainFormHotkeyController : IDisposable
var prev = list.SelectedIndex;
list.BeginUpdate();
list.Items.Clear();
// Order matches the case-block dispatchers in ChangeSelected and UnsetSelected
// below. Local-action hotkeys first (rows 0..5: send / receive / tray / volume×2 /
// recording), then the remote-app trio (rows 6..8), then the Windows-system trio
// (rows 9..11). Toggle recording joined the local group at index 5 in v1.5
// (2026-05-15) — natural fit alongside the other "this machine" toggles.
list.Items.Add($"Toggle sending audio: {sendMuteHotkey}");
list.Items.Add($"Toggle receiving audio: {receiveMuteHotkey}");
list.Items.Add($"Show or hide window: {trayHotkey}");
list.Items.Add($"Volume up for received sound on this machine: {volumeUpHotkey}");
list.Items.Add($"Volume down for received sound on this machine: {volumeDownHotkey}");
list.Items.Add($"Start / Stop recording: {toggleRecordingHotkey}");
list.Items.Add($"Send remote volume up to peers: {remoteVolumeUpHotkey}");
list.Items.Add($"Send remote volume down to peers: {remoteVolumeDownHotkey}");
list.Items.Add($"Send remote receive mute toggle to peers: {remoteMuteToggleHotkey}");
@@ -269,12 +288,13 @@ internal sealed class MainFormHotkeyController : IDisposable
case 2: ChangeTrayHotkey(dialog); break;
case 3: ChangeVolumeUpHotkey(dialog); break;
case 4: ChangeVolumeDownHotkey(dialog); break;
case 5: ChangeRemoteVolumeUpHotkey(dialog); break;
case 6: ChangeRemoteVolumeDownHotkey(dialog); break;
case 7: ChangeRemoteMuteToggleHotkey(dialog); break;
case 8: ChangeSystemVolumeUpHotkey(dialog); break;
case 9: ChangeSystemVolumeDownHotkey(dialog); break;
case 10: ChangeSystemMuteToggleHotkey(dialog); break;
case 5: ChangeToggleRecordingHotkey(dialog); break;
case 6: ChangeRemoteVolumeUpHotkey(dialog); break;
case 7: ChangeRemoteVolumeDownHotkey(dialog); break;
case 8: ChangeRemoteMuteToggleHotkey(dialog); break;
case 9: ChangeSystemVolumeUpHotkey(dialog); break;
case 10: ChangeSystemVolumeDownHotkey(dialog); break;
case 11: ChangeSystemMuteToggleHotkey(dialog); break;
default: return;
}
RefreshList();
@@ -299,12 +319,13 @@ internal sealed class MainFormHotkeyController : IDisposable
case 2: ApplyUnset("tray", h => trayHotkey = h, RegisterTrayHotkey, settingsStore.SaveTrayHotkey); break;
case 3: ApplyUnset("volume-up", h => volumeUpHotkey = h, RegisterVolumeUpHotkey, settingsStore.SaveVolumeUpHotkey); break;
case 4: ApplyUnset("volume-down", h => volumeDownHotkey = h, RegisterVolumeDownHotkey, settingsStore.SaveVolumeDownHotkey); break;
case 5: ApplyUnset("send-remote-volume-up", h => remoteVolumeUpHotkey = h, RegisterRemoteVolumeUpHotkey, settingsStore.SaveRemoteVolumeUpHotkey); break;
case 6: ApplyUnset("send-remote-volume-down", h => remoteVolumeDownHotkey = h, RegisterRemoteVolumeDownHotkey, settingsStore.SaveRemoteVolumeDownHotkey); break;
case 7: ApplyUnset("send-remote-mute-toggle", h => remoteMuteToggleHotkey = h, RegisterRemoteMuteToggleHotkey, settingsStore.SaveRemoteMuteToggleHotkey); break;
case 8: ApplyUnset("send-system-volume-up", h => systemVolumeUpHotkey = h, RegisterSystemVolumeUpHotkey, settingsStore.SaveSystemVolumeUpHotkey); break;
case 9: ApplyUnset("send-system-volume-down", h => systemVolumeDownHotkey = h, RegisterSystemVolumeDownHotkey, settingsStore.SaveSystemVolumeDownHotkey); break;
case 10: ApplyUnset("send-system-mute-toggle", h => systemMuteToggleHotkey = h, RegisterSystemMuteToggleHotkey, settingsStore.SaveSystemMuteToggleHotkey); break;
case 5: ApplyUnset("toggle-recording", h => toggleRecordingHotkey = h, RegisterToggleRecordingHotkey, settingsStore.SaveToggleRecordingHotkey); break;
case 6: ApplyUnset("send-remote-volume-up", h => remoteVolumeUpHotkey = h, RegisterRemoteVolumeUpHotkey, settingsStore.SaveRemoteVolumeUpHotkey); break;
case 7: ApplyUnset("send-remote-volume-down", h => remoteVolumeDownHotkey = h, RegisterRemoteVolumeDownHotkey, settingsStore.SaveRemoteVolumeDownHotkey); break;
case 8: ApplyUnset("send-remote-mute-toggle", h => remoteMuteToggleHotkey = h, RegisterRemoteMuteToggleHotkey, settingsStore.SaveRemoteMuteToggleHotkey); break;
case 9: ApplyUnset("send-system-volume-up", h => systemVolumeUpHotkey = h, RegisterSystemVolumeUpHotkey, settingsStore.SaveSystemVolumeUpHotkey); break;
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;
default: return;
}
RefreshList();
@@ -377,6 +398,7 @@ internal sealed class MainFormHotkeyController : IDisposable
trayGlobalHotkey?.Dispose();
volumeUpGlobalHotkey?.Dispose();
volumeDownGlobalHotkey?.Dispose();
toggleRecordingGlobalHotkey?.Dispose();
remoteVolumeUpGlobalHotkey?.Dispose();
remoteVolumeDownGlobalHotkey?.Dispose();
remoteMuteToggleGlobalHotkey?.Dispose();
@@ -390,6 +412,7 @@ internal sealed class MainFormHotkeyController : IDisposable
public HotkeyInfo TrayHotkey => trayHotkey;
public HotkeyInfo VolumeUpHotkey => volumeUpHotkey;
public HotkeyInfo VolumeDownHotkey => volumeDownHotkey;
public HotkeyInfo ToggleRecordingHotkey => toggleRecordingHotkey;
public HotkeyInfo RemoteVolumeUpHotkey => remoteVolumeUpHotkey;
public HotkeyInfo RemoteVolumeDownHotkey => remoteVolumeDownHotkey;
public HotkeyInfo RemoteMuteToggleHotkey => remoteMuteToggleHotkey;
@@ -478,6 +501,13 @@ internal sealed class MainFormHotkeyController : IDisposable
settingsStore.SaveVolumeDownHotkey(h);
});
private void ChangeToggleRecordingHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "toggle-recording", h =>
{
toggleRecordingHotkey = h;
RegisterToggleRecordingHotkey();
settingsStore.SaveToggleRecordingHotkey(h);
});
private void ChangeRemoteVolumeUpHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-remote-volume-up", h =>
{
remoteVolumeUpHotkey = h;
@@ -535,6 +565,10 @@ internal sealed class MainFormHotkeyController : IDisposable
private void RegisterTrayHotkey() => RegisterIfSet(trayGlobalHotkey, trayHotkey, "tray");
private void RegisterVolumeUpHotkey() => RegisterIfSet(volumeUpGlobalHotkey, volumeUpHotkey, "volume up", allowRepeat: true);
private void RegisterVolumeDownHotkey() => RegisterIfSet(volumeDownGlobalHotkey, volumeDownHotkey, "volume down", allowRepeat: true);
// Toggle recording is a one-shot toggle (press → flip Start/Stop). MOD_NOREPEAT
// (allowRepeat: false, the default) prevents a held key from flipping the recording
// state on/off/on/off at keyboard auto-repeat rate.
private void RegisterToggleRecordingHotkey() => RegisterIfSet(toggleRecordingGlobalHotkey, toggleRecordingHotkey, "toggle recording");
private void RegisterRemoteVolumeUpHotkey() => RegisterIfSet(remoteVolumeUpGlobalHotkey, remoteVolumeUpHotkey, "send remote volume up", allowRepeat: true);
private void RegisterRemoteVolumeDownHotkey() => RegisterIfSet(remoteVolumeDownGlobalHotkey, remoteVolumeDownHotkey, "send remote volume down", allowRepeat: true);
private void RegisterRemoteMuteToggleHotkey() => RegisterIfSet(remoteMuteToggleGlobalHotkey, remoteMuteToggleHotkey, "send remote mute toggle");
+22 -35
View File
@@ -12,14 +12,16 @@ namespace RemSound.App;
/// "Mute connect/disconnect sounds" toggle (2026-05-15) when recording start/stop cues
/// were added — a CheckedListBox scales to future cues without dialog re-layout.
/// * Accept remote volume commands from peers — opt-in for the remote-control feature.
/// * Startup behaviour — opens the existing <see cref="StartupBehaviourDialog"/> sub-dialog.
/// * Update settings — frequency, manual check, silent-install toggle.
/// * Enable logs + Write logs now.
///
/// Startup behaviour was previously a button here that opened <see cref="StartupBehaviourDialog"/>;
/// it's now a top-level Options menu item in its own right (2026-05-15 menu reorg).
///
/// All settings save through <see cref="RemSoundSettingsStore"/> or <see cref="AppConfig"/>
/// on every change (no OK-to-commit). Esc or Close dismisses.
///
/// Reachable via the File → Preferences menu item or Ctrl+P from the main window.
/// Reachable via the Options → Preferences menu item or Ctrl+P from the main window.
/// </summary>
internal sealed class PreferencesDialog : Form
{
@@ -67,13 +69,6 @@ internal sealed class PreferencesDialog : Form
AutoSize = true,
};
private readonly Button startupBehaviourButton = new()
{
Text = "Startup behaviour... (Alt+&S)",
AccessibleName = "Startup behaviour",
AutoSize = true,
};
// Update settings — frequency dropdown, manual check button, silent-install checkbox.
// Sits above the logging row so users meet it during setup; the canonical order in the
// dialog is "things related to the program staying current" before "things related to
@@ -214,14 +209,6 @@ internal sealed class PreferencesDialog : Form
ChangedAnyProfileSetting = true;
};
startupBehaviourButton.Click += (_, _) =>
{
using var dialog = new StartupBehaviourDialog(profileStore);
dialog.ShowDialog(this);
// Startup behaviour persists through AppConfig + registry directly, so we
// don't need to flag profile-dirty for that.
};
// Update settings — wired against AppConfig directly since they're machine-local.
// The frequency combo's index maps 1:1 to the UpdateCheckFrequency enum so reordering
// either side stays in lockstep.
@@ -264,25 +251,26 @@ internal sealed class PreferencesDialog : Form
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 1,
RowCount = 10,
RowCount = 9,
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
for (var i = 0; i < 9; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
for (var i = 0; i < 8; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
// Tab order top-to-bottom: browse, cue-sound list, accept remote, startup, update
// frequency, check-now, silent install, enable logs, write logs now, close. Updates
// sit above the log row so a user setting up the app meets them first.
// Tab order top-to-bottom: browse, cue-sound list, accept remote, update frequency,
// check-now, silent install, enable logs, write logs now, close. Updates sit above
// the log row so a user setting up the app meets them first. The Startup behaviour
// button used to live here at tab index 3; it moved to the Options menu in the
// 2026-05-15 reorg.
browseProfilesFolderButton.TabIndex = 0;
cueList.TabIndex = 1;
acceptRemoteVolumeBox.TabIndex = 2;
startupBehaviourButton.TabIndex = 3;
updateFrequencyBox.TabIndex = 4;
checkForUpdatesNowButton.TabIndex = 5;
silentlyInstallUpdatesBox.TabIndex = 6;
loggingBox.TabIndex = 7;
writeLogsNowButton.TabIndex = 8;
closeButton.TabIndex = 9;
updateFrequencyBox.TabIndex = 3;
checkForUpdatesNowButton.TabIndex = 4;
silentlyInstallUpdatesBox.TabIndex = 5;
loggingBox.TabIndex = 6;
writeLogsNowButton.TabIndex = 7;
closeButton.TabIndex = 8;
// Group the frequency label + combo on one FlowLayoutPanel row so the visible label
// sits inline next to the combo while keeping the combo as the focusable target.
@@ -317,12 +305,11 @@ internal sealed class PreferencesDialog : Form
panel.Controls.Add(browseProfilesFolderButton, 0, 0);
panel.Controls.Add(cueGroup, 0, 1);
panel.Controls.Add(acceptRemoteVolumeBox, 0, 2);
panel.Controls.Add(startupBehaviourButton, 0, 3);
panel.Controls.Add(freqRow, 0, 4);
panel.Controls.Add(checkForUpdatesNowButton, 0, 5);
panel.Controls.Add(silentlyInstallUpdatesBox, 0, 6);
panel.Controls.Add(loggingBox, 0, 7);
panel.Controls.Add(writeLogsNowButton, 0, 8);
panel.Controls.Add(freqRow, 0, 3);
panel.Controls.Add(checkForUpdatesNowButton, 0, 4);
panel.Controls.Add(silentlyInstallUpdatesBox, 0, 5);
panel.Controls.Add(loggingBox, 0, 6);
panel.Controls.Add(writeLogsNowButton, 0, 7);
var buttons = new FlowLayoutPanel
{
+1 -1
View File
@@ -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>1.4.0</Version>
<Version>1.5.0</Version>
</PropertyGroup>
<ItemGroup>