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:
co-authored by
Claude Opus 4.7
parent
b96ba82378
commit
4915a9afe1
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -89,6 +89,33 @@ public sealed class AppConfig
|
||||
/// only once. Null on a fresh install.</summary>
|
||||
public DateTime? LastUpdateCheckUtc { get; set; }
|
||||
|
||||
/// <summary>Most-recently-opened profile paths, newest first, capped at
|
||||
/// <see cref="MaxRecentProfiles"/>. Populated by <see cref="NoteRecentProfile"/> every
|
||||
/// time a profile is loaded, surfaced in the File → Recent profiles submenu. Stored as
|
||||
/// full paths so profiles saved outside the canonical profiles folder are also
|
||||
/// reachable (Save-As to an arbitrary path stays in the recents list).</summary>
|
||||
public List<string> RecentProfiles { get; set; } = new();
|
||||
|
||||
/// <summary>Cap on how many entries we keep in <see cref="RecentProfiles"/>. Five is the
|
||||
/// most that fits comfortably as 1–5 single-digit mnemonics inside a submenu without
|
||||
/// the user needing to read the names to remember which row they want.</summary>
|
||||
public const int MaxRecentProfiles = 5;
|
||||
|
||||
/// <summary>Push a profile path to the front of the recents list. Removes any existing
|
||||
/// entry that matches (case-insensitive) so a recently re-opened profile rises to the
|
||||
/// top instead of being duplicated. Caps the list at <see cref="MaxRecentProfiles"/>.
|
||||
/// Caller must <see cref="Save"/> after mutating.</summary>
|
||||
public void NoteRecentProfile(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
RecentProfiles.RemoveAll(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase));
|
||||
RecentProfiles.Insert(0, path);
|
||||
while (RecentProfiles.Count > MaxRecentProfiles)
|
||||
{
|
||||
RecentProfiles.RemoveAt(RecentProfiles.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConfigPath => Path.Combine(AppContext.BaseDirectory, "remsound.config.json");
|
||||
|
||||
/// <summary>Reads the app config from disk. Always returns a non-null instance — a missing
|
||||
|
||||
@@ -131,6 +131,12 @@ public sealed class Profile
|
||||
public HotkeyRecord? TrayHotkey { get; set; }
|
||||
public HotkeyRecord? VolumeUpHotkey { get; set; }
|
||||
public HotkeyRecord? VolumeDownHotkey { get; set; }
|
||||
/// <summary>Global hotkey for start / stop recording. Toggles the same action as the
|
||||
/// Record menu's "Start recording / Stop recording" item and the in-app Ctrl+R, but
|
||||
/// works system-wide (RemSound doesn't need keyboard focus). Default unset — recording
|
||||
/// is uncommon enough that we don't claim a default chord that might clash with the
|
||||
/// user's other tools.</summary>
|
||||
public HotkeyRecord? ToggleRecordingHotkey { get; set; }
|
||||
/// <summary>Hotkey that sends a "raise volume" command to every connected peer that has
|
||||
/// "Accept remote volume commands" enabled. The local volume slider on this machine is
|
||||
/// NOT touched. Use case: I'm NVDA-Remote'd into another machine and want to nudge the
|
||||
|
||||
@@ -64,6 +64,16 @@ public sealed class RemSoundSettingsStore
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public HotkeyInfo LoadToggleRecordingHotkey() =>
|
||||
Try(() => Load()?.ToggleRecordingHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
|
||||
|
||||
public void SaveToggleRecordingHotkey(HotkeyInfo hotkey)
|
||||
{
|
||||
var s = Load() ?? new Settings();
|
||||
s.ToggleRecordingHotkey = HotkeySetting.From(hotkey);
|
||||
Save(s);
|
||||
}
|
||||
|
||||
public HotkeyInfo LoadRemoteVolumeUpHotkey() =>
|
||||
Try(() => Load()?.RemoteVolumeUpHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
|
||||
|
||||
@@ -466,6 +476,7 @@ public sealed class RemSoundSettingsStore
|
||||
TrayHotkey = profile.TrayHotkey is null ? null : HotkeySettingFromRecord(profile.TrayHotkey),
|
||||
VolumeUpHotkey = profile.VolumeUpHotkey is null ? null : HotkeySettingFromRecord(profile.VolumeUpHotkey),
|
||||
VolumeDownHotkey = profile.VolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.VolumeDownHotkey),
|
||||
ToggleRecordingHotkey = profile.ToggleRecordingHotkey is null ? null : HotkeySettingFromRecord(profile.ToggleRecordingHotkey),
|
||||
RemoteVolumeUpHotkey = profile.RemoteVolumeUpHotkey is null ? null : HotkeySettingFromRecord(profile.RemoteVolumeUpHotkey),
|
||||
RemoteVolumeDownHotkey = profile.RemoteVolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.RemoteVolumeDownHotkey),
|
||||
RemoteMuteToggleHotkey = profile.RemoteMuteToggleHotkey is null ? null : HotkeySettingFromRecord(profile.RemoteMuteToggleHotkey),
|
||||
@@ -512,6 +523,7 @@ public sealed class RemSoundSettingsStore
|
||||
profile.TrayHotkey = s.TrayHotkey is null ? null : HotkeyRecordFromSetting(s.TrayHotkey);
|
||||
profile.VolumeUpHotkey = s.VolumeUpHotkey is null ? null : HotkeyRecordFromSetting(s.VolumeUpHotkey);
|
||||
profile.VolumeDownHotkey = s.VolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.VolumeDownHotkey);
|
||||
profile.ToggleRecordingHotkey = s.ToggleRecordingHotkey is null ? null : HotkeyRecordFromSetting(s.ToggleRecordingHotkey);
|
||||
profile.RemoteVolumeUpHotkey = s.RemoteVolumeUpHotkey is null ? null : HotkeyRecordFromSetting(s.RemoteVolumeUpHotkey);
|
||||
profile.RemoteVolumeDownHotkey = s.RemoteVolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.RemoteVolumeDownHotkey);
|
||||
profile.RemoteMuteToggleHotkey = s.RemoteMuteToggleHotkey is null ? null : HotkeyRecordFromSetting(s.RemoteMuteToggleHotkey);
|
||||
@@ -570,6 +582,7 @@ public sealed class RemSoundSettingsStore
|
||||
public HotkeySetting? TrayHotkey { get; set; }
|
||||
public HotkeySetting? VolumeUpHotkey { get; set; }
|
||||
public HotkeySetting? VolumeDownHotkey { get; set; }
|
||||
public HotkeySetting? ToggleRecordingHotkey { get; set; }
|
||||
public HotkeySetting? RemoteVolumeUpHotkey { get; set; }
|
||||
public HotkeySetting? RemoteVolumeDownHotkey { get; set; }
|
||||
public HotkeySetting? RemoteMuteToggleHotkey { get; set; }
|
||||
|
||||
@@ -136,11 +136,14 @@ public sealed class AudioReceiver : IDisposable
|
||||
/// <summary>
|
||||
/// Optional callback invoked when the engine produces fully-processed mixed received
|
||||
/// audio (volume / mute / limiter all applied). Span is 48 kHz interleaved stereo
|
||||
/// float, lives on the render thread — copy or consume quickly. Used by the recorder
|
||||
/// to capture "what we heard". Setter mirrors directly onto <see cref="PlayoutEngine"/>;
|
||||
/// null clears the tap.
|
||||
/// float, lives on the render thread — copy or consume quickly. The
|
||||
/// <see cref="RenderRoute"/> tag identifies which lane fired the callback (Mixed in
|
||||
/// classic modes; WasapiLane or AsioLane in BothIndependent where each lane Reads
|
||||
/// independently). The recorder uses the tag to keep per-lane streams separate and
|
||||
/// mix them at drain time rather than appending sequentially. Setter mirrors directly
|
||||
/// onto <see cref="PlayoutEngine"/>; null clears the tap.
|
||||
/// </summary>
|
||||
public Action<ReadOnlyMemory<float>>? OnReceivedSamples
|
||||
public Action<ReadOnlyMemory<float>, RenderRoute>? OnReceivedSamples
|
||||
{
|
||||
get => playoutEngine.OnReceivedSamples;
|
||||
set => playoutEngine.OnReceivedSamples = value;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using NAudio.Wave;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.Receiver;
|
||||
|
||||
@@ -170,6 +171,15 @@ internal sealed class CompositeRenderBackend : IRenderBackend
|
||||
// activity / inactivity doesn't affect the other lane's read path. The "skip the
|
||||
// pull when no outputs are ticked" behaviour now lives inside MultiOutputPlayout's
|
||||
// producer loop, which short-circuits source.Read when outputs.Count == 0.
|
||||
|
||||
// Tell the PlayoutEngine which lanes have an active output device. ReadForRoute
|
||||
// uses this to route "orphan" sessions (those whose announced lane has no active
|
||||
// output) through whichever lane IS being read — so a peer that announced WASAPI
|
||||
// is still audible on a receiver who has only ASIO outputs ticked. Without this
|
||||
// signal those sessions stay stuck in their session ring and the user just hears
|
||||
// silence from that peer. 2026-05-15.
|
||||
source.SetLaneActive(RenderRoute.WasapiLane, wasapiIds.Count > 0);
|
||||
source.SetLaneActive(RenderRoute.AsioLane, asioIds.Count > 0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -68,6 +68,18 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
private readonly LaneLatency mixedLatency = new();
|
||||
private readonly LaneLatency wasapiLaneLatency = new();
|
||||
private readonly LaneLatency asioLaneLatency = new();
|
||||
// Per-lane active flag — true when an output device is ticked for that lane in
|
||||
// BothIndependent mode (i.e. the lane's render backend is actually pulling from the
|
||||
// corresponding LaneOutput). Set by <see cref="SetLaneActive"/> from
|
||||
// CompositeRenderBackend whenever the user changes the output-device tick state.
|
||||
// Used by <see cref="ReadForRoute"/> to fold "orphan" sessions whose announced route
|
||||
// has no active output through whichever lane IS active — so a peer sending on its
|
||||
// WASAPI lane is still audible on a user who only has an ASIO output device ticked.
|
||||
// Both default to true so a fresh PlayoutEngine that hasn't yet been told (e.g. in
|
||||
// unit tests, or before the first SetOutputDevices call) behaves like the old
|
||||
// strict-filter path.
|
||||
private volatile bool wasapiLaneActive = true;
|
||||
private volatile bool asioLaneActive = true;
|
||||
private volatile bool muted;
|
||||
private volatile float volume = 1f;
|
||||
// 1 = stupid aggressive, 10 = perfectly smooth. Read on the audio thread, written from UI.
|
||||
@@ -82,6 +94,27 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
|
||||
public void SetSmoothness(int value) => smoothness = Math.Clamp(value, 1, 10);
|
||||
|
||||
/// <summary>Signal that a render-side lane's output device set just changed. <c>active</c>
|
||||
/// is true if the lane has at least one output device ticked (its render backend is
|
||||
/// actively pulling from the corresponding LaneOutput), false if no device is ticked.
|
||||
/// Called from CompositeRenderBackend.SetOutputDevices.
|
||||
///
|
||||
/// Sessions tagged with an inactive lane fall through to whichever lane IS active during
|
||||
/// <see cref="ReadForRoute"/> — so a peer sending WASAPI-lane audio is still audible
|
||||
/// when the receiver only has an ASIO output device ticked, and vice versa. If both
|
||||
/// lanes are inactive (no output device ticked at all) nothing's calling Read in the
|
||||
/// first place; the orphan-fall-through logic is benign in that case.</summary>
|
||||
public void SetLaneActive(RenderRoute lane, bool active)
|
||||
{
|
||||
switch (lane)
|
||||
{
|
||||
case RenderRoute.WasapiLane: wasapiLaneActive = active; break;
|
||||
case RenderRoute.AsioLane: asioLaneActive = active; break;
|
||||
// RenderRoute.Mixed is handled by ReadAllSessions which doesn't filter by lane;
|
||||
// no flag needed.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sets the concealment artifact for every active session and for any future
|
||||
/// session created after this call. Live-updates: the next time a session sees an
|
||||
/// underrun, it uses the new artifact.</summary>
|
||||
@@ -169,13 +202,19 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
/// streams paced at the per-lane render rates) — the recorder consolidates them on its
|
||||
/// background thread. In classic modes only the main Read path fires.
|
||||
/// </summary>
|
||||
public Action<ReadOnlyMemory<float>>? OnReceivedSamples { get; set; }
|
||||
public Action<ReadOnlyMemory<float>, RenderRoute>? OnReceivedSamples { get; set; }
|
||||
|
||||
private void DispatchReceivedSamples(ReadOnlyMemory<float> samples)
|
||||
/// <summary>Forward a fully-processed mix to the recording tap, tagged with the lane it
|
||||
/// came from so BothIndependent mode (where this method is called from BOTH the WASAPI
|
||||
/// lane Read AND the ASIO lane Read independently) doesn't write doubled audio to the
|
||||
/// recorder. The recorder uses the tag to keep per-lane streams separate and mix them
|
||||
/// at drain time. The classic-mode all-sessions Read passes <see cref="RenderRoute.Mixed"/>;
|
||||
/// per-lane Reads pass their own route.</summary>
|
||||
private void DispatchReceivedSamples(ReadOnlyMemory<float> samples, RenderRoute lane)
|
||||
{
|
||||
var cb = OnReceivedSamples;
|
||||
if (cb is null) return;
|
||||
try { cb(samples); } catch { /* recorder failure isolated from audio path */ }
|
||||
try { cb(samples, lane); } catch { /* recorder failure isolated from audio path */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -539,11 +578,34 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
var routeTargetMs = routeLatency.TargetMs;
|
||||
var routeMaxMs = routeLatency.MaxMs;
|
||||
|
||||
// Session inclusion rule:
|
||||
// * Match the route directly (session.Route == route) — the normal case.
|
||||
// * Or this session's announced route has NO active output device — fall through
|
||||
// onto whichever lane IS being read. This is how a WASAPI-tagged peer becomes
|
||||
// audible when the receiver only has an ASIO output ticked (and vice versa).
|
||||
// Without the second clause, ticking only one of the two BothIndependent lanes' output
|
||||
// devices stranded any session announced on the other lane in its session ring — heard
|
||||
// as silence by the user, eventually overflowing. 2026-05-15 fix.
|
||||
//
|
||||
// The "other lane is inactive" check is computed once per Read since the active-flag
|
||||
// state can only change via SetLaneActive (UI-thread driven).
|
||||
var otherLaneActive = route switch
|
||||
{
|
||||
RenderRoute.WasapiLane => asioLaneActive,
|
||||
RenderRoute.AsioLane => wasapiLaneActive,
|
||||
_ => true, // Mixed never reaches this path (uses ReadAllSessions instead)
|
||||
};
|
||||
var aggregateBufferedBytes = 0;
|
||||
var anyContributed = false;
|
||||
foreach (var session in snap)
|
||||
{
|
||||
if (session.Route != route) continue;
|
||||
var matchesOwnLane = session.Route == route;
|
||||
// Orphan = session tagged for the OTHER non-Mixed lane, and that lane has no
|
||||
// active output. (Mixed-tagged sessions never appear in BothIndependent.)
|
||||
var isOrphanFromOtherLane = !matchesOwnLane
|
||||
&& session.Route != RenderRoute.Mixed
|
||||
&& !otherLaneActive;
|
||||
if (!matchesOwnLane && !isOrphanFromOtherLane) continue;
|
||||
aggregateBufferedBytes += session.BufferedBytes;
|
||||
var produced = session.ReadFloats(sessionBuf.AsSpan(0, outFloats), outFrames, routeTargetMs, routeMaxMs, smoothness);
|
||||
if (produced <= 0) continue;
|
||||
@@ -586,10 +648,11 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
|
||||
// Recording tap (per-lane). Mix is fully processed at this point — volume, mute and
|
||||
// limiter have all been applied — so the recorder sees exactly what the user is
|
||||
// about to hear from this lane. The tap fires regardless of whether the lane has an
|
||||
// attached output device; that way a recording set to "received only" still captures
|
||||
// audio in setups where the user has no WASAPI outputs ticked.
|
||||
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats));
|
||||
// about to hear from this lane. Tagged with `route` so the recorder can keep WASAPI-
|
||||
// lane and ASIO-lane streams separate (each lane fires this method independently in
|
||||
// BothIndependent; without the tag both ended up in one recorder ring, doubling the
|
||||
// file's effective sample rate).
|
||||
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), route);
|
||||
|
||||
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
||||
return count;
|
||||
@@ -662,9 +725,10 @@ internal sealed class PlayoutEngine : IWaveProvider
|
||||
|
||||
// Recording tap (all-sessions path, classic modes / WasapiOnly). Same point in the
|
||||
// pipeline as the lane-routed tap above — fully processed mix, just before the
|
||||
// pack-to-bytes step. The recorder gets a clean copy of what the user is about
|
||||
// to hear.
|
||||
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats));
|
||||
// pack-to-bytes step. Tagged with RenderRoute.Mixed; the recorder maps Mixed to its
|
||||
// wasapi-slot ring (canonical single-lane slot in classic modes), so this fires
|
||||
// exactly once per real-time second.
|
||||
DispatchReceivedSamples(mixBuf.AsMemory(0, outFloats), RenderRoute.Mixed);
|
||||
|
||||
Buffer.BlockCopy(mixBuf, 0, buffer, offset, outFloats * sizeof(float));
|
||||
return count;
|
||||
|
||||
@@ -179,10 +179,12 @@ public sealed class AudioSender : IDisposable
|
||||
/// Optional callback invoked every time a SenderLane is about to encode a buffer of
|
||||
/// captured float audio. The span is 48 kHz interleaved stereo float, lives on the
|
||||
/// audio thread, and must be processed quickly or copied — the buffer is reused on
|
||||
/// the very next callback. The recorder uses this tap to capture "what we sent" with
|
||||
/// zero impact on the wire path (no allocation, no extra encoder pass). Null = no tap.
|
||||
/// the very next callback. The <see cref="RenderRoute"/> tag identifies which
|
||||
/// SenderLane invoked the callback (Mixed in classic modes; WasapiLane or AsioLane in
|
||||
/// BothIndependent) so the recorder can keep per-lane streams separate and mix them
|
||||
/// at drain time rather than appending sequentially. Null = no tap.
|
||||
/// </summary>
|
||||
public Action<ReadOnlyMemory<float>>? OnSentSamples { get; set; }
|
||||
public Action<ReadOnlyMemory<float>, RenderRoute>? OnSentSamples { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Internal helper for <see cref="SenderLane"/> to invoke <see cref="OnSentSamples"/>
|
||||
@@ -190,11 +192,11 @@ public sealed class AudioSender : IDisposable
|
||||
/// any exception from the user callback — a misbehaving recorder must not crash the
|
||||
/// audio thread.
|
||||
/// </summary>
|
||||
internal void DispatchSentSamples(ReadOnlyMemory<float> samples)
|
||||
internal void DispatchSentSamples(ReadOnlyMemory<float> samples, RenderRoute lane)
|
||||
{
|
||||
var cb = OnSentSamples;
|
||||
if (cb is null) return;
|
||||
try { cb(samples); } catch { /* recorder failure isolated from audio path */ }
|
||||
try { cb(samples, lane); } catch { /* recorder failure isolated from audio path */ }
|
||||
}
|
||||
|
||||
public AudioSender()
|
||||
|
||||
@@ -158,8 +158,12 @@ internal sealed class SenderLane
|
||||
// Recording tap — the recorder gets the float audio about to be encoded. The lane
|
||||
// doesn't know whether the recorder is running; the dispatcher early-outs when no
|
||||
// callback is wired. Captured here (before encoding) so the recording is bit-clean
|
||||
// float, independent of which codec the wire is using.
|
||||
owner.DispatchSentSamples(stereoFloats);
|
||||
// float, independent of which codec the wire is using. The lane tag is forwarded so
|
||||
// BothIndependent mode (where both WASAPI and ASIO SenderLanes fire on every capture
|
||||
// callback) can be correctly handled by the recorder — each lane writes into its own
|
||||
// ring, and the recorder mixes them rather than appending them sequentially (which
|
||||
// would double the file's effective sample rate). 2026-05-15 fix.
|
||||
owner.DispatchSentSamples(stereoFloats, route);
|
||||
|
||||
// Discontinuity probe — what does the audio look like just before we encode it?
|
||||
// Compared to the receiver's per-stage probes, this tells us whether artefacts are
|
||||
|
||||
Reference in New Issue
Block a user