diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5e6c43a..aae8dbb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,37 +1,36 @@ -# RemSound v1.4 +# RemSound v1.5 -Recording-settings dialog cleanup and a few mnemonic adjustments. No wire-format or audio-pipeline changes — v1.4 and v1.3 peers interoperate. +Menu reorganisation plus two real bug fixes for BothIndependent mode. Wire format and audio pipeline are unchanged from v1.4 — v1.4 and v1.5 peers interoperate. -## Highlights +## Bug fixes -### Recording settings dialog reorganised +- **BothIndependent recording**: when both WASAPI and ASIO output devices were ticked, recordings came out garbled and roughly double the expected duration. The recording taps fired from both lanes independently and the writer thread appended both streams into a single ring as if they were sequential audio. The recorder now has per-lane rings and mixes them in the writer thread before handing the resulting per-direction stream to the file writer. +- **A peer announcing on the WASAPI lane was inaudible when the receiver only had an ASIO output ticked** (and vice versa). The session was opened but its decoded samples sat in the SessionPlayout ring with nothing draining them. Sessions whose announced lane has no active output device now fall through to whichever lane IS being read — so a single ASIO output plays everyone, regardless of how each peer announced their stream. -- **Channel mode** is now its own dedicated listbox (**Alt+C**) instead of being folded into every attribute row. The per-format attribute lists shrink correspondingly: - - **WAV** — 6 rows → **3** (16-bit PCM / 24-bit PCM / 32-bit float) - - **MP3** — 8 rows → **4** (128 / 192 / 256 / 320 kbps CBR) - - **OGG-Opus** — 8 rows → **4** (96 / 128 / 192 / 256 kbps VBR) - - **FLAC** — 4 rows → **2** (16-bit / 24-bit) -- **FLAC compression level** (0..8) now selectable in its own listbox (**Alt+L**). Previously hard-fixed at the libFLAC default of 5. The list is only shown when the file format is FLAC, with friendly tags on the endpoints (`0 — fastest encode, biggest file`, `5 — default (libFLAC reference)`, `8 — slowest encode, smallest file`). All levels produce bit-identical lossless audio — it's a pure encode-time vs file-size trade-off. +## Menu reorganisation -### Record menu mnemonics +- **New Options menu (Alt+O)** holds *Recording settings*, *Keyboard shortcuts*, *Startup behaviour*, and *Preferences*. Pre-v1.5 these were scattered across the File menu (Keyboard shortcuts, Preferences), the Record menu (Recording settings), and a button inside the Preferences dialog itself (Startup behaviour). +- **Record menu mnemonic moves 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 the Receive audio checkbox; Alt+O has the natural home on the Options menu now. +- **File menu — new Recent profiles submenu (Alt+F, R)**. Lists the five most-recently-opened profiles, newest first. Press 1..5 while the submenu is open to jump straight to a slot; arrow + Enter also works. Missing files (e.g. external drive unmounted) are skipped from the menu but kept in storage in case they come back. +- **File menu — small mnemonic shuffle** to make room for Recent profiles: *Rename current profile* moves from Alt+M to Alt+M (was R), and *Minimise to tray* moves from M to **N**. +- **Lock to audio clock** (Audio profile tab) was Alt+K; now **Alt+D** (the D from "au**d**io"). Top-level menus win Alt-letter dispatch on a Form, so Record taking Alt+K bumped the checkbox. -- **Start / Stop recording** is now `Alt+O, R` (was `Alt+O, S`). Matches the `Ctrl+R` global toggle so the same letter does the same job from either entry point. The underline stays on a literal *R* as the label flips between "Sta**r**t recording" and "Stop **r**ecording". -- **Recording settings** is now `Alt+O, S` (was `Alt+O, T`). Reads more naturally now that the *R* slot is freed. -- **Open folder** (`Alt+O, O`) and **Change folder** (`Alt+O, C`) unchanged. +## UX additions -### Dialog mnemonic adjustment - -- The **Cancel** button in the Recording settings dialog now uses **Alt+N** (`Ca&ncel`) rather than the conventional Alt+C, so the **Channels** listbox can take Alt+C as its natural letter. Esc still dismisses the dialog the way it always has. +- **Ctrl+O** is now bound to Open profile (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 so it doesn't collide with anything on a fresh install. Works system-wide — RemSound doesn't need keyboard focus. ## Install -1. Download `RemSound-v1.4.zip` from this release. -2. Extract somewhere with write permission (e.g. `C:\RemSound\`, `Documents\RemSound\`, etc.). Avoid `Program Files` unless you grant write permission so the self-updater can replace files in place. +1. Download `RemSound-v1.5.zip` from this release. +2. Extract somewhere with write permission (e.g. `C:\RemSound\`, `Documents\RemSound\`). Avoid `Program Files` unless you grant write permission so the self-updater can replace files in place. 3. Run `RemSound.exe`. Allow on private networks when Windows Firewall prompts. 4. Press F1 (or use the Help menu) for the user manual. Requires the .NET 10 Desktop Runtime. If it's missing, Windows offers to fetch it on first launch. -## Upgrading from v1.3 inside a Dropbox folder +## Upgrading from v1.3 / v1.4 -Should work cleanly via Help → Check for updates. v1.3 introduced the hardened helper (60-second retry window, exit-code check, no silent rollback) so the Dropbox-lock window is no longer a problem. If you're on v1.0/v1.1/v1.2 in a Dropbox folder, see the v1.3 release notes for the one-time manual install steps — once on v1.3 or later, future auto-updates are reliable. +If you installed RemSound inside a Dropbox-synced (or other file-sync) folder and your install is v1.0 / v1.1 / v1.2, see the [v1.3 release notes](https://github.com/Ednunp/RemSound/releases/tag/v1.3) for one-time manual install steps. From v1.3 onward Check-for-updates handles Dropbox correctly. + +v1.3 and v1.4 users on any install location can use **Help → Check for updates** — the hardened updater in v1.3+ pulls v1.5 cleanly. diff --git a/readme.html b/readme.html index 6532bb5..d6515cd 100644 --- a/readme.html +++ b/readme.html @@ -31,7 +31,7 @@ ul, ol { padding-left: 1.4em; }
  • Quick start
  • Profiles
  • The main window: menu bar + three tabs
  • -
  • Menus (File, Record (Alt+O), and Help)
  • +
  • Menus (File, Record, Options, Help)
  • Connectivity tab
  • Audio inputs and outputs tab
  • Audio profile tab
  • @@ -40,7 +40,7 @@ ul, ol { padding-left: 1.4em; }
  • How the network works (LAN, WAN, Tailscale)
  • Latency and audio quality
  • Keyboard shortcuts
  • -
  • Global hotkeys (mute, volume, tray)
  • +
  • Global hotkeys (mute, volume, tray, recording)
  • Remote control: adjusting a peer's listening volume from your end
  • Startup behaviour
  • Updating RemSound
  • @@ -125,7 +125,7 @@ ul, ol { padding-left: 1.4em; } - +
    ActionHow
    Switch to a different profileFile → Open profile (Alt+F, O). Pick a JSON in the file picker. RemSound reloads under that profile.
    Rename the active profileFile → Rename current profile (Alt+F, R). Prompts for the new name and renames the JSON on disk; window title updates immediately.
    Rename the active profileFile → Rename current profile (Alt+F, M). Prompts for the new name and renames the JSON on disk; window title updates immediately.
    Delete a profileFile → Open profile, then right-click the entry in the Windows file picker and choose Delete. Hands the action off to Windows Explorer rather than reimplementing it inside RemSound.
    @@ -170,9 +170,9 @@ ul, ol { padding-left: 1.4em; } Audio profileCodec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into Audio send parameters and Audio receive parameters sections so NVDA announces which group you’ve entered as you tab through. - + -

    Three top-level menus on the main window: File (Alt+F), Record (Alt+O), and Help (Alt+H). The Record menu uses Alt+O rather than Alt+R so it doesn’t clash with the Receive audio (Alt+R) checkbox on the main form.

    +

    Four top-level menus on the main window: File (Alt+F), Record (Alt+K), Options (Alt+O), and Help (Alt+H). The Record menu's mnemonic is the unusual Alt+K — chosen because Alt+R is taken by the Receive audio checkbox on the main form, and Alt+O has the natural fit on the Options menu. The Record menu's title is rendered as “Record (Alt+K)” so the chord is discoverable despite K not being a letter in the word.

    File menu

    @@ -180,26 +180,38 @@ ul, ol { padding-left: 1.4em; } - + + - - - - + +
    ItemShortcutWhat it does
    Open profile…Alt+F, OOpens a Windows file picker rooted at the profiles folder. Pick a JSON, RemSound reloads under that profile (window closes and reopens with all its device ticks, peers, and settings restored). To delete a profile from disk, right-click an entry inside the file picker and choose Delete — that hands the action off to Windows Explorer.
    Open profile…Ctrl+O, or Alt+F, OOpens a Windows file picker rooted at the profiles folder. Pick a JSON, RemSound reloads under that profile (window closes and reopens with all its device ticks, peers, and settings restored). To delete a profile from disk, right-click an entry inside the file picker and choose Delete — that hands the action off to Windows Explorer.
    Recent profiles →Alt+F, RSubmenu listing the last five profiles you’ve opened, most-recent first. Each row carries a single-digit mnemonic: press 1 while the submenu is open to jump to the most recent, 2 for the second most-recent, and so on up to 5. Or arrow down and press Enter on the row you want. Same close-and-relaunch behaviour as Open profile. If a recent profile’s file has been deleted or moved away, it’s skipped from the submenu (the entry stays in storage in case the file comes back — e.g. an external drive remount). Empty list shows a disabled “(No recent profiles)” entry.
    SaveCtrl+SOverwrites the active profile with the current state. If there’s no active profile (you’re on Blank template), this falls through to Save as automatically.
    Save as…Alt+F, APrompts for a profile name and saves a copy. Use this to fork the current state under a new name, or to save the first time from Blank template.
    Rename current profile…Alt+F, RRenames the JSON file on disk and updates the window title. No-op on Blank template (no profile to rename).
    Minimise to trayAlt+F, MHides the window to the system tray. Reachable via the File menu chain (Alt+F to open the menu, then M for Minimise). To bring the window back, click the tray icon or use the configurable "Show or hide window" global hotkey (Keyboard shortcuts dialog, default Ctrl+Shift+F10).
    Keyboard shortcuts…Ctrl+KOpens the global hotkey configuration dialog (mute, volume, tray show/hide, remote-control sends).
    Preferences…Ctrl+POpens the Preferences dialog. Sticky machine-local choices live here: profile folder, the Cue sounds (Alt+N) checklist (one tickable item per cue: connect, disconnect, recording start, recording stop), accept remote volume commands, startup behaviour, update-check frequency, manual check-for-updates button, silently install updates, enable logs, write logs now. Esc or the Close button dismisses.
    Rename current profile…Alt+F, MRenames the JSON file on disk and updates the window title. No-op on Blank template (no profile to rename). Mnemonic is M (Rename) — was R until v1.5 when Recent profiles took the R slot.
    Minimise to trayAlt+F, NHides the window to the system tray. Mnemonic is N (Minimise) — was M until v1.5 when Rename took the M slot. To bring the window back, click the tray icon or use the configurable “Show or hide window” global hotkey (Keyboard shortcuts dialog, default Ctrl+Shift+F10).
    ExitAlt+F, X (or Alt+F4)Closes RemSound. If there are unsaved profile changes you’ll be prompted first.

    Record menu

    -

    Press Alt+O to open the menu, then the underlined letter for the item you want. (Alt+O rather than Alt+R because Alt+R is taken on the main form by the Receive audio checkbox.) The recording feature taps the audio path at the right place to capture either what you’re sending, what you’re receiving, or both, and writes it to disk as a WAV, MP3, OGG-Opus, or FLAC file. See Recording to a file for the full chapter; this is the menu summary.

    +

    Press Alt+K to open the menu, then the underlined letter for the item you want. (Alt+K rather than the natural Alt+R because Alt+R is taken on the main form by the Receive audio checkbox; Alt+O moved to the Options menu in v1.5.) The recording feature taps the audio path at the right place to capture either what you’re sending, what you’re receiving, or both, and writes it to disk as a WAV, MP3, OGG-Opus, or FLAC file. See Recording to a file for the full chapter; this is the menu summary.

    + + + + + + +
    ItemShortcutWhat it does
    Start recording / Stop recordingCtrl+R, or Alt+K, RToggle. The label flips between “Start recording” and “Stop recording” so NVDA announces whichever action the next press would perform. Either label is activated by Alt+R (the underline stays on a letter R in both). Each press of Start plays a short cue sound (if enabled in Preferences), then creates a new file in your recordings folder named RemSound-yyyy-MM-dd_HH-mm-ss.<ext>. Stop closes the file then plays the stop cue. Ctrl+R works from anywhere in the main window.
    Open current recordings folderAlt+K, OOpens your current recordings folder in Windows Explorer. Creates the folder if it doesn’t exist yet (first launch after a fresh install).
    Change recordings folder…Alt+K, CFolder picker. Pick a different folder for future recordings. The choice is saved on the current profile, so different profiles can record to different places.
    + +

    Recording settings used to live here as a third menu item; it moved to the Options menu in v1.5.

    + +

    Options menu

    + +

    Press Alt+O to open the menu, then the underlined letter for the item you want. The Options menu collects everything you’d want to configure about the app — recording settings, keyboard shortcuts, startup behaviour, general preferences. Pre-v1.5 these were scattered across File and Record menus and the Preferences dialog itself.

    - - - + + +
    ItemShortcutWhat it does
    Start recording / Stop recordingCtrl+R, or Alt+O, RToggle. The label flips between “Start recording” and “Stop recording” so NVDA announces whichever action the next press would perform. Either label is activated by Alt+R (the underline stays on a letter R in both). Each press of Start plays a short cue sound (if enabled in Preferences), then creates a new file in your recordings folder named RemSound-yyyy-MM-dd_HH-mm-ss.<ext>. Stop closes the file then plays the stop cue. Ctrl+R works from anywhere in the main window.
    Recording settings…Alt+O, SOpens the Recording settings dialog. Up to five listboxes: Recording source (Alt+S), File format (Alt+F), Audio format attributes (Alt+A), FLAC compression level (Alt+L — visible only when FLAC is selected), Channels (Alt+C). The attributes list repopulates based on the selected format. OK saves to the current profile; Cancel discards.
    Open current recordings folderAlt+O, OOpens your current recordings folder in Windows Explorer. Creates the folder if it doesn’t exist yet (first launch after a fresh install).
    Change recordings folder…Alt+O, CFolder picker. Pick a different folder for future recordings. The choice is saved on the current profile, so different profiles can record to different places.
    Keyboard shortcuts…Ctrl+K, or Alt+O, KOpens the global hotkey configuration dialog (mute, volume, tray show/hide, start/stop recording, remote-control sends).
    Startup behaviour…Alt+O, TOpens the Startup behaviour dialog directly (in v1.4 and earlier this was a button inside Preferences). Choose whether to auto-launch with Windows, which profile to load by default, and whether to start minimised to tray.
    Preferences…Ctrl+P, or Alt+O, POpens the Preferences dialog. Sticky machine-local choices live here: profile folder, the Cue sounds (Alt+N) checklist (one tickable item per cue: connect, disconnect, recording start, recording stop), accept remote volume commands, update-check frequency, manual check-for-updates button, silently install updates, enable logs, write logs now. Esc or the Close button dismisses.

    Help menu

    @@ -256,6 +268,8 @@ ul, ol { padding-left: 1.4em; }
  • Two physical outputs for left/right room monitoring.
  • +

    Cross-lane routing (v1.5+): a peer always announces which lane it’s sending from (WASAPI lane or ASIO lane). On the receiving side, if you have only one type of output device ticked, audio from a peer’s opposite lane is still routed through whatever output IS ticked. Pre-v1.5 a WASAPI-lane peer was inaudible to a receiver who only had ASIO outputs ticked (and vice versa) — the session opened but never produced audio. Now a single ticked output is enough to hear everything.

    +

    Sending

    Sending requires Send my audio ticked, plus at least one capture source ticked across the three send lists.

    @@ -311,7 +325,7 @@ ul, ol { padding-left: 1.4em; } ControlShortcutWhat it does Audio codecAlt+CPCM 48k 24-bit, Opus high quality (20 ms), or Opus lower quality (10 ms). See codec choice. Packet sizeAlt+PStandard (default) or Small (LAN only). Smaller packets save a couple of milliseconds of send-side accumulator latency at the cost of doubling the packet rate. -Lock to audio clockAlt+KSender-side timing tightener. Locks packet emission to the audio device’s hardware clock instead of a Stopwatch loop. Saves a few milliseconds of jitter; brief clicks possible if the link can’t keep up. The label changes depending on whether ASIO is in the pipeline so it tells you what it actually does in your current setup. +Lock to audio clockAlt+DSender-side timing tightener. Locks packet emission to the audio device’s hardware clock instead of a Stopwatch loop. Saves a few milliseconds of jitter; brief clicks possible if the link can’t keep up. The label changes depending on whether ASIO is in the pipeline so it tells you what it actually does in your current setup. Mnemonic was Alt+K until v1.5 (Record menu took Alt+K at the menu-bar level) — now Alt+D, the D from “audio”.

    Audio receive parameters

    @@ -397,7 +411,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L

    Even if a peer is sending audio in your direction, you won’t hear it until you’ve ticked their checkbox. This is deliberate — connecting is a consent step. A peer’s name shows up in Discovered the moment they come online, but they can’t make sound on your speakers until you say yes.

    Heartbeat indicator

    -

    For each connected peer, the status read-out at the bottom of the window shows a small status: their latest round-trip time in milliseconds, or pending, stale, or unreachable if heartbeat replies have stopped. RemSound plays a connect cue (a short sound) when a peer transitions to healthy and a disconnect cue when one becomes unreachable. Both cues can be silenced via Mute connect/disconnect sounds in the Preferences dialog (File → Preferences, or Ctrl+P).

    +

    For each connected peer, the status read-out at the bottom of the window shows a small status: their latest round-trip time in milliseconds, or pending, stale, or unreachable if heartbeat replies have stopped. RemSound plays a connect cue (a short sound) when a peer transitions to healthy and a disconnect cue when one becomes unreachable. Both cues can be silenced via Mute connect/disconnect sounds in the Preferences dialog (Options → Preferences, or Ctrl+P).

    11. How the network works (LAN, WAN, Tailscale)

    @@ -465,7 +479,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
  • Audio latency in milliseconds (Alt+L) — the headline target buffer size at the receiver.
  • Buffer smoothness (Alt+B) — how aggressively the receiver protects against jitter spikes.
  • Packet size (Alt+P) — Standard or Small. Smaller packets shave a couple of milliseconds off send-side delay at the cost of doubling packet rate.
  • -
  • Lock to audio clock (Alt+K) — ties packet emission to the audio device’s hardware clock end-to-end, eliminating Windows scheduler jitter.
  • +
  • Lock to audio clock (Alt+D) — ties packet emission to the audio device’s hardware clock end-to-end, eliminating Windows scheduler jitter.
  • Continuous auto-tune — lets the receiver choose the latency target for you, every few seconds.
  • @@ -570,7 +584,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L Alt+SFocus Connection status read-out -

    (Logging controls — Enable logs and Write logs now — live in the Preferences dialog; reach them via File → Preferences or Ctrl+P, then Alt+L / Alt+W within the dialog.)

    +

    (Logging controls — Enable logs and Write logs now — live in the Preferences dialog; reach them via Options → Preferences or Ctrl+P, then Alt+L / Alt+W within the dialog.)

    Audio inputs and outputs tab

    @@ -595,7 +609,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L - + @@ -612,14 +626,18 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L - - - - + + + + - - + + + + + +
    Alt+UToggle Use CPU and Windows performance settings in high priority mode (for this profile)
    Alt+CFocus Audio codec
    Alt+PFocus Packet size
    Alt+KToggle Lock to audio clock
    Alt+DToggle Lock to audio clock
    Alt+LFocus latency spinner — ASIO lane when an ASIO driver is selected, otherwise the single Audio latency spinner
    Alt+TToggle continuous auto-tune — ASIO lane when an ASIO driver is selected, otherwise the single Continuous auto-tune toggle
    Alt+W(Only when an ASIO driver is selected.) Focus the WASAPI-lane latency spinner
    Ctrl+KOpen Keyboard shortcuts dialog
    Ctrl+POpen Preferences dialog
    Ctrl+RStart or stop recording (toggles)
    Alt+O, RStart or stop recording (via menu — Record menu is Alt+O, item is R for “recording”)
    Alt+O, SRecording settings dialog
    Alt+O, OOpen current recordings folder
    Alt+O, CChange recordings folder
    Alt+K, RStart or stop recording (via menu — Record menu is Alt+K, item is R for “recording”)
    Alt+K, OOpen current recordings folder
    Alt+K, CChange recordings folder
    Alt+F, OOpen profile
    Alt+F, RRecent profiles (submenu — then 1..5 for the matching slot)
    Alt+F, ASave profile as
    Alt+F, RRename current profile
    Alt+F, MMinimise to tray
    Alt+F, MRename current profile
    Alt+F, NMinimise to tray
    Alt+O, SRecording settings
    Alt+O, KKeyboard shortcuts
    Alt+O, TStartup behaviour
    Alt+O, PPreferences
    Alt+F, XExit
    @@ -636,7 +654,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L

    14. Global hotkeys (work even when minimised)

    -

    Configurable in the Keyboard shortcuts dialog (Ctrl+K, or File → Keyboard shortcuts). The dialog is a single list of every bindable shortcut: arrow up and down to move between rows, press Enter to rebind the highlighted row, press Del to clear it (back to not set), press Escape (or Tab to the Close button) to close. Defaults:

    +

    Configurable in the Keyboard shortcuts dialog (Ctrl+K, or Options → Keyboard shortcuts). The dialog is a single list of every bindable shortcut: arrow up and down to move between rows, press Enter to rebind the highlighted row, press Del to clear it (back to not set), press Escape (or Tab to the Close button) to close. Defaults:

    @@ -644,6 +662,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L + @@ -710,7 +729,7 @@ Bind whatever key combinations you prefer (e.g. Ctrl+Shift+Up / Ctrl+Shift+Down

    16. Startup behaviour

    -

    Open the Startup behaviour dialog from File → Preferences → Startup behaviour (or Ctrl+P, then Alt+S). It has three independent toggles, plus a profile picker that appears when the third one is on. Tab cycles between the three checkboxes, the profile list (when visible), and the Close button. Esc closes the dialog. Each tick is saved immediately — there's no OK/Apply button.

    +

    Open the Startup behaviour dialog from Options → Startup behaviour (Alt+O, T). It has three independent toggles, plus a profile picker that appears when the third one is on. Tab cycles between the three checkboxes, the profile list (when visible), and the Close button. Esc closes the dialog. Each tick is saved immediately — there's no OK/Apply button. (Before v1.5 this dialog was reached by a button inside Preferences; it's now its own top-level Options-menu item.)

    HotkeyActionDefault
    Send muteMute / unmute outgoing audio (this machine)Ctrl+Shift+Alt+S
    Tray toggleShow / hide the main windowCtrl+Shift+F10
    Volume up / downAdjust this machine's received-audio volumeUnset
    Start / Stop recordingStart or stop a recording on this machine. Same toggle as the Record menu’s start/stop item and the in-app Ctrl+R, but works system-wide (RemSound doesn’t need keyboard focus). See Recording to a file for what gets captured.Unset
    Send remote volume up to peersTell every connected peer to raise their RemSound app volume slider by 5 points (only honoured by peers that have ticked “Accept remote volume commands”). Doesn't change your own volume. See Remote control.Unset
    Send remote volume down to peersMirror, lower direction.Unset
    Send remote receive mute toggle to peersTell every connected peer to toggle their RemSound receive mute.Unset
    @@ -739,7 +758,7 @@ Bind whatever key combinations you prefer (e.g. Ctrl+Shift+Up / Ctrl+Shift+Down

    Settings in Preferences

    -

    Open File → Preferences (or Ctrl+P). The update settings sit above the logging row:

    +

    Open Options → Preferences (or Ctrl+P). The update settings sit above the logging row:

    ToggleWhat it does
    @@ -818,10 +837,11 @@ Bind whatever key combinations you prefer (e.g. Ctrl+Shift+Up / Ctrl+Shift+Down

    Starting and stopping

    -

    Three ways to start or stop a recording:

    +

    Four ways to start or stop a recording:

    diff --git a/src/RemSound.App/AboutDialog.cs b/src/RemSound.App/AboutDialog.cs index 13d7f5f..4caacfe 100644 --- a/src/RemSound.App/AboutDialog.cs +++ b/src/RemSound.App/AboutDialog.cs @@ -20,6 +20,57 @@ internal sealed class AboutDialog : Form /// updates" path. 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 diff --git a/src/RemSound.App/AudioRecorder.cs b/src/RemSound.App/AudioRecorder.cs index d6965c0..b39bbc1 100644 --- a/src/RemSound.App/AudioRecorder.cs +++ b/src/RemSound.App/AudioRecorder.cs @@ -76,17 +76,41 @@ internal sealed class AudioRecorder : IDisposable private readonly Action? onDiagnostic; private readonly Action? 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 === /// 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. - public void WriteSent(ReadOnlyMemory stereoFloats) + /// source mode is "received only". The 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. + public void WriteSent(ReadOnlyMemory 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); + } } /// 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. - public void WriteReceived(ReadOnlyMemory stereoFloats) + /// source mode is "sent only". tags which PlayoutEngine + /// per-lane Read invoked us — same RenderRoute mapping as . + /// Lock-free, allocation-free; safe to call from the render thread. + public void WriteReceived(ReadOnlyMemory 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); + } } /// 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 }; } + /// Drain one direction worth of audio into , merging the + /// WASAPI-lane and ASIO-lane rings into a single stream. Behaviour: + /// * Both lanes have frames available: drain min(wasapi, asio, maxFrames), + /// 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 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. + private static int DrainOneDirection( + float[] wasapiRing, ref long wasapiWriteHead, ref long wasapiReadHead, + float[] asioRing, ref long asioWriteHead, ref long asioReadHead, + Span dst, Span 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 diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index af1b5cd..5574afd 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -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; } + /// Rebuild the Recent profiles submenu from . + /// 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). + 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", + }); + } + } + + /// Switch to the profile at 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. + 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(); + } + /// 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. 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; diff --git a/src/RemSound.App/MainFormHotkeyController.cs b/src/RemSound.App/MainFormHotkeyController.cs index e2e9391..5ed6fa9 100644 --- a/src/RemSound.App/MainFormHotkeyController.cs +++ b/src/RemSound.App/MainFormHotkeyController.cs @@ -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"); diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs index 80a75d5..79dd4da 100644 --- a/src/RemSound.App/PreferencesDialog.cs +++ b/src/RemSound.App/PreferencesDialog.cs @@ -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 sub-dialog. /// * Update settings — frequency, manual check, silent-install toggle. /// * Enable logs + Write logs now. /// +/// Startup behaviour was previously a button here that opened ; +/// it's now a top-level Options menu item in its own right (2026-05-15 menu reorg). +/// /// All settings save through or /// 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. /// 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 { diff --git a/src/RemSound.App/RemSound.App.csproj b/src/RemSound.App/RemSound.App.csproj index ca7ed7a..189d587 100644 --- a/src/RemSound.App/RemSound.App.csproj +++ b/src/RemSound.App/RemSound.App.csproj @@ -14,7 +14,7 @@ tag_name on the latest GitHub release; bump it on every public release. The AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version is what the About dialog and the updater both read. --> - 1.4.0 + 1.5.0 diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs index 3ed3f07..0be07d4 100644 --- a/src/RemSound.Core/AppConfig.cs +++ b/src/RemSound.Core/AppConfig.cs @@ -89,6 +89,33 @@ public sealed class AppConfig /// only once. Null on a fresh install. public DateTime? LastUpdateCheckUtc { get; set; } + /// Most-recently-opened profile paths, newest first, capped at + /// . Populated by 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). + public List RecentProfiles { get; set; } = new(); + + /// Cap on how many entries we keep in . 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. + public const int MaxRecentProfiles = 5; + + /// 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 . + /// Caller must after mutating. + 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"); /// Reads the app config from disk. Always returns a non-null instance — a missing diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs index 809641b..28ce4f4 100644 --- a/src/RemSound.Core/Profile.cs +++ b/src/RemSound.Core/Profile.cs @@ -131,6 +131,12 @@ public sealed class Profile public HotkeyRecord? TrayHotkey { get; set; } public HotkeyRecord? VolumeUpHotkey { get; set; } public HotkeyRecord? VolumeDownHotkey { get; set; } + /// 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. + public HotkeyRecord? ToggleRecordingHotkey { get; set; } /// 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 diff --git a/src/RemSound.Core/RemSoundSettingsStore.cs b/src/RemSound.Core/RemSoundSettingsStore.cs index 2cea145..da82356 100644 --- a/src/RemSound.Core/RemSoundSettingsStore.cs +++ b/src/RemSound.Core/RemSoundSettingsStore.cs @@ -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; } diff --git a/src/RemSound.Receiver/AudioReceiver.cs b/src/RemSound.Receiver/AudioReceiver.cs index 0321e6b..482c9be 100644 --- a/src/RemSound.Receiver/AudioReceiver.cs +++ b/src/RemSound.Receiver/AudioReceiver.cs @@ -136,11 +136,14 @@ public sealed class AudioReceiver : IDisposable /// /// 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 ; - /// null clears the tap. + /// float, lives on the render thread — copy or consume quickly. The + /// 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 ; null clears the tap. /// - public Action>? OnReceivedSamples + public Action, RenderRoute>? OnReceivedSamples { get => playoutEngine.OnReceivedSamples; set => playoutEngine.OnReceivedSamples = value; diff --git a/src/RemSound.Receiver/CompositeRenderBackend.cs b/src/RemSound.Receiver/CompositeRenderBackend.cs index d1000e1..7b52606 100644 --- a/src/RemSound.Receiver/CompositeRenderBackend.cs +++ b/src/RemSound.Receiver/CompositeRenderBackend.cs @@ -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() diff --git a/src/RemSound.Receiver/PlayoutEngine.cs b/src/RemSound.Receiver/PlayoutEngine.cs index 3a9f9ff..af4c54f 100644 --- a/src/RemSound.Receiver/PlayoutEngine.cs +++ b/src/RemSound.Receiver/PlayoutEngine.cs @@ -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 from + // CompositeRenderBackend whenever the user changes the output-device tick state. + // Used by 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); + /// Signal that a render-side lane's output device set just changed. active + /// 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 + /// — 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. + 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. + } + } + /// 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. @@ -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. /// - public Action>? OnReceivedSamples { get; set; } + public Action, RenderRoute>? OnReceivedSamples { get; set; } - private void DispatchReceivedSamples(ReadOnlyMemory samples) + /// 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 ; + /// per-lane Reads pass their own route. + private void DispatchReceivedSamples(ReadOnlyMemory 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 */ } } /// @@ -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; diff --git a/src/RemSound.Sender/AudioSender.cs b/src/RemSound.Sender/AudioSender.cs index 5fdb106..36ea23e 100644 --- a/src/RemSound.Sender/AudioSender.cs +++ b/src/RemSound.Sender/AudioSender.cs @@ -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 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. /// - public Action>? OnSentSamples { get; set; } + public Action, RenderRoute>? OnSentSamples { get; set; } /// /// Internal helper for to invoke @@ -190,11 +192,11 @@ public sealed class AudioSender : IDisposable /// any exception from the user callback — a misbehaving recorder must not crash the /// audio thread. /// - internal void DispatchSentSamples(ReadOnlyMemory samples) + internal void DispatchSentSamples(ReadOnlyMemory 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() diff --git a/src/RemSound.Sender/SenderLane.cs b/src/RemSound.Sender/SenderLane.cs index c34cacc..69b24d6 100644 --- a/src/RemSound.Sender/SenderLane.cs +++ b/src/RemSound.Sender/SenderLane.cs @@ -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
    SettingShortcutWhat it does