Two new cues join the existing connect / disconnect / record-start /
record-stop set:
* profile-save cue (sounds\save.wav by default) fires in
SaveProfileTo after a successful Save or Save As. Honours the
Profile.EnableSaveCue per-profile flag.
* profile-switch cue (sounds\profile.wav by default) fires in the
MainForm Shown handler after a profile finishes loading - covers
both startup-with-profile and mid-session profile switches.
Honours the Profile.EnableProfileSwitchCue flag. Because cue
loading runs AFTER settings.ApplyProfile in the ctor (line 542),
the profile being entered determines which sound plays - not the
one being left, exactly as Ed asked.
The audio cue UI in PreferencesDialog stays as a CheckedListBox (up /
down navigates, Space toggles) with TWO action buttons below that
operate on whichever cue is selected:
* Play [cue name] (Alt+P) - previews via SoundPlayer.Play on the
resolved path (custom override if set, default in sounds\
otherwise). Independent of the tick state.
* Browse for [cue name]... (Alt+B) - opens a WAV picker. If the
user picks a file inside RemSound's own sounds\ folder, it's
treated as "use default" and the override is cleared - avoids
pinning the user to a specific shipped default that a future
release might replace. Right-click "Use default sound" reverts.
Custom cue paths AND enable flags are per-profile. Lives on
Profile.CustomCuePaths (Dictionary<string,string>) and the per-cue
EnableXxxCue bool? properties. Cache mirror in
RemSoundSettingsStore.Settings.
All default WAVs moved from the install-root flat layout into a
sounds\ subfolder (csproj Content rules updated). save.wav and
profile.wav are bundled defaults.
Tray menu rewritten (MainFormTrayController) per Ed's spec:
* Show RemSound (W) - now uses Win32 SetForegroundWindow after the
standard Activate() because WinForms Activate is blocked by the
foreground-lock when invoked from a tray-menu click, which left
screen-reader users having to Alt+Tab to reach the restored
window.
* Enable sending (S) / Enable receiving (R) - tickable, reflect
current state, TOGGLE rather than always-on.
* Profiles (P) - submenu populated from AppConfig.RecentProfiles
with the same &1..&5 mnemonics the File menu uses. Pre-populated
once at construction so WinForms recognises it as a submenu and
fires DropDownOpening - originally I relied entirely on the
open event, which the framework skipped for items with no
DropDownItems, producing the "Profiles does nothing" bug.
* Exit (X).
Tray tooltip now built dynamically from snapshot tick (1 Hz):
"RemSound - [recording for MM:SS,] N peer(s), sending (lane),
receiving (lane)". Recording timer only included while
RecordingController.IsRecording is true (added
RecordingStartedUtc accessor for the elapsed calculation). Lane is
derived from which device-list ticks are active, not just the audio-
mode setting, so a BothIndependent user with only WASAPI inputs ticked
honestly reads as "sending (WASAPI)".
Fixes:
* Initial tooltip "RemSound" produced a "RemSound RemSound" read on
NVDA because the process name and tooltip matched. Set to
"RemSound - starting up" so the duplicate disappears.
* Recent profile menu items no longer carry a "Recent profile N:"
AccessibleName prefix in either the tray submenu or the File
menu's Recent profiles - now just the profile name. Number-key
mnemonics (&1..&5) untouched.
Manual (readme.html) updated: new section 17 "Audio cue sounds"
documents all six cues, the Play/Browse buttons, the right-click
"Use default sound", and the per-profile semantics. Sections 17-21
renumbered to 18-22. New "System tray icon and its menu" subsection
inside section 4 documents the redesigned right-click menu and the
hover tooltip. MANUAL.md regenerated via sync-manual.py. About box
gets a v3.1 block at the top. RELEASE_NOTES.md fully rewritten for
v3.1.
No wire format change - v3.1 talks to other v3.0.x machines exactly
as before.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
161 lines
6.4 KiB
C#
161 lines
6.4 KiB
C#
using RemSound.Core;
|
|
using RemSound.Receiver;
|
|
using RemSound.Sender;
|
|
|
|
namespace RemSound.App;
|
|
|
|
/// <summary>
|
|
/// Glue between MainForm's Record menu and the actual recording pipeline. Owns the
|
|
/// lifecycle of the currently-running <see cref="AudioRecorder"/> (if any) and wires
|
|
/// the sender / receiver taps to it. Reading the user's saved settings, persisting
|
|
/// changes after the settings dialog, opening / changing the recordings folder — all
|
|
/// flow through here so MainForm stays focused on UI wiring.
|
|
///
|
|
/// Threading: the public methods are called from the UI thread only. The recorder
|
|
/// itself runs on its own background thread (it owns a queue + writer); the controller
|
|
/// just constructs and disposes it.
|
|
/// </summary>
|
|
internal sealed class RecordingController
|
|
{
|
|
private readonly AudioSender sender;
|
|
private readonly AudioReceiver receiver;
|
|
private readonly RemSoundSettingsStore settings;
|
|
private readonly Action<string> diagnostic;
|
|
private AudioRecorder? active;
|
|
|
|
public RecordingController(AudioSender sender, AudioReceiver receiver, RemSoundSettingsStore settings, Action<string> diagnostic)
|
|
{
|
|
this.sender = sender;
|
|
this.receiver = receiver;
|
|
this.settings = settings;
|
|
this.diagnostic = diagnostic;
|
|
}
|
|
|
|
public bool IsRecording => active is not null;
|
|
|
|
/// <summary>UTC clock at which the current recording started, or null when nothing is
|
|
/// recording. Captured by <see cref="Start"/> and cleared by <see cref="Stop"/>. Used
|
|
/// by the system-tray tooltip builder in MainForm to surface "recording for MM:SS"
|
|
/// alongside the peer count. 2026-05-28.</summary>
|
|
public DateTime? RecordingStartedUtc { get; private set; }
|
|
|
|
/// <summary>Optional callback fired when the user starts or stops a recording. The
|
|
/// MainForm hooks this to flip the menu item text "Start recording" ↔ "Stop recording"
|
|
/// and announce the change to NVDA.</summary>
|
|
public event Action<bool>? RecordingStateChanged;
|
|
|
|
/// <summary>Start a new recording using the currently-saved profile settings. If a
|
|
/// recording is already running this is a no-op (the menu shouldn't ever offer Start
|
|
/// while recording, but the guard is here for safety).</summary>
|
|
public void Start()
|
|
{
|
|
if (active is not null) return;
|
|
var s = settings.LoadRecordingSettings();
|
|
try
|
|
{
|
|
active = new AudioRecorder(s, diagnostic, OnRecorderFinished);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
diagnostic($"recording: failed to start: {ex.GetType().Name}: {ex.Message}");
|
|
MessageBox.Show(
|
|
$"Could not start recording:\n\n{ex.Message}",
|
|
"RemSound — recording",
|
|
MessageBoxButtons.OK,
|
|
MessageBoxIcon.Warning);
|
|
return;
|
|
}
|
|
|
|
// Wire taps. Each tap is independent — the recorder's source-mode filter decides
|
|
// whether to actually write the samples.
|
|
sender.OnSentSamples = active.WriteSent;
|
|
receiver.OnReceivedSamples = active.WriteReceived;
|
|
RecordingStartedUtc = DateTime.UtcNow;
|
|
diagnostic($"recording: started → {active.FilePath} (source={s.Source}, format={s.FileFormat}, channels={s.ChannelMode})");
|
|
RecordingStateChanged?.Invoke(true);
|
|
}
|
|
|
|
/// <summary>Stop the currently-running recording. Unhooks taps, flushes the writer
|
|
/// queue, closes the file, and surfaces the resulting path in a brief MessageBox
|
|
/// so the user knows where the file landed.</summary>
|
|
public void Stop()
|
|
{
|
|
var recorder = active;
|
|
if (recorder is null) return;
|
|
|
|
// Unhook taps FIRST so no more audio gets queued during the drain.
|
|
sender.OnSentSamples = null;
|
|
receiver.OnReceivedSamples = null;
|
|
|
|
active = null;
|
|
RecordingStartedUtc = null;
|
|
try
|
|
{
|
|
recorder.Stop();
|
|
recorder.Dispose();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
diagnostic($"recording: stop threw {ex.GetType().Name}: {ex.Message}");
|
|
}
|
|
RecordingStateChanged?.Invoke(false);
|
|
}
|
|
|
|
private void OnRecorderFinished(string path, long bytes)
|
|
{
|
|
diagnostic($"recording: finished → {path} ({bytes:N0} bytes)");
|
|
}
|
|
|
|
/// <summary>Open the currently-configured recordings folder in Windows Explorer.
|
|
/// Creates the folder if it doesn't yet exist (a fresh install hasn't recorded
|
|
/// anything, so the folder won't be there). Surfaces filesystem errors to the user
|
|
/// rather than swallowing them silently.</summary>
|
|
public void OpenCurrentFolder(IWin32Window? owner)
|
|
{
|
|
var s = settings.LoadRecordingSettings();
|
|
var folder = s.ResolvedFolder();
|
|
try
|
|
{
|
|
Directory.CreateDirectory(folder);
|
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = folder,
|
|
UseShellExecute = true,
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
diagnostic($"recording: open folder failed: {ex.GetType().Name}: {ex.Message}");
|
|
MessageBox.Show(owner,
|
|
$"Could not open recordings folder:\n\n{ex.Message}",
|
|
"RemSound — recordings folder",
|
|
MessageBoxButtons.OK,
|
|
MessageBoxIcon.Warning);
|
|
}
|
|
}
|
|
|
|
/// <summary>Show a folder-picker rooted at the current recordings folder. If the
|
|
/// user picks a different folder, save it on the profile and return true so the
|
|
/// caller can flag the profile dirty.</summary>
|
|
public bool ChangeFolder(IWin32Window? owner)
|
|
{
|
|
var s = settings.LoadRecordingSettings();
|
|
var startFolder = s.ResolvedFolder();
|
|
using var picker = new FolderBrowserDialog
|
|
{
|
|
Description = "Choose a folder for RemSound recordings",
|
|
UseDescriptionForTitle = true,
|
|
SelectedPath = Directory.Exists(startFolder) ? startFolder : RecordingSettings.DefaultFolder(),
|
|
ShowNewFolderButton = true,
|
|
};
|
|
if (picker.ShowDialog(owner) != DialogResult.OK) return false;
|
|
if (string.IsNullOrWhiteSpace(picker.SelectedPath)) return false;
|
|
if (string.Equals(picker.SelectedPath, startFolder, StringComparison.OrdinalIgnoreCase)) return false;
|
|
|
|
s.Folder = picker.SelectedPath;
|
|
settings.SaveRecordingSettings(s);
|
|
diagnostic($"recording: folder changed → {picker.SelectedPath}");
|
|
return true;
|
|
}
|
|
}
|