Release v3.3: end-to-end encrypted audio, plus cue and reliability fixes
Headline: all audio is now encrypted (AES-256-GCM), keyed by a per-profile password. Mandatory — v3.3 only interoperates with v3.3+. Encryption - RemSoundCrypto (Core): PBKDF2 key derivation, AES-GCM encrypt/decrypt (low-alloc, into-span), password fingerprint, light on-disk obfuscation. - Wire: SenderLane encrypts the audio payload (PCM split across parts when the +28 overhead crosses MTU); AudioReceiver/StreamSession decrypt via a shared single-thread AudioDecryptor. Fingerprint piggybacks on the Format packet (offset 36, backward-compatible) so a peer can detect a password mismatch. - Profile.Password (scrambled), carried through BuildCurrentProfile; MainForm derives + pushes the key/fingerprint to sender + receiver (RecomputeAudioCrypto). - UX: ask-for-password on profile create; File -> Change this profile's password (ProfilePasswordDialog); Options -> Profile passwords (manager); a gate that prompts before streaming without a password; and a clear "passwords don't match" / "peer needs to update" message driven by the fingerprint. Cue fixes - CuePlayer (NAudio) replaces System.Media.SoundPlayer, which silently failed on the 96 kHz/24-bit cue WAVs (and any custom file) — cues now play reliably, resampled to 48 kHz/16-bit. Also fixes the Preferences preview button. - Connect/disconnect cues now audio-gated with hysteresis: connected when audio flows OR heartbeat healthy; lost only when audio stops AND heartbeat unreachable. Kills false disconnects and the receive-only "no cues" case. - Honest cue logging (played / muted / not loaded). Smaller - Endpoint stickiness: keep the audio target pinned to the heartbeat-proven address instead of chasing a multi-homed peer's other (unreachable) address. - "Online/offline" label now audio+heartbeat aware, not discovery-only. - "Show what's new after each update" preference (on by default). Docs: About v3.3 block, RELEASE_NOTES, README (encryption as a headline), manual section 12 "Passwords and encryption" (+ renumber), MANUAL.md regenerated. Version 3.2.0 -> 3.3.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cdcac859c4
commit
959720f54d
@@ -19,17 +19,18 @@ It was built for playing music together over the internet — a guitarist on one
|
|||||||
1. [ASIO and WASAPI](#9-asio-and-wasapi)
|
1. [ASIO and WASAPI](#9-asio-and-wasapi)
|
||||||
1. [Peers — finding and connecting](#10-peers--finding-and-connecting)
|
1. [Peers — finding and connecting](#10-peers--finding-and-connecting)
|
||||||
1. [How the network works (LAN, WAN, Tailscale)](#11-how-the-network-works-lan-wan-tailscale)
|
1. [How the network works (LAN, WAN, Tailscale)](#11-how-the-network-works-lan-wan-tailscale)
|
||||||
1. [Latency and audio quality](#12-latency-and-audio-quality)
|
1. [Passwords and encryption](#12-passwords-and-encryption)
|
||||||
1. [Keyboard shortcuts (within the main window)](#13-keyboard-shortcuts-within-the-main-window)
|
1. [Latency and audio quality](#13-latency-and-audio-quality)
|
||||||
1. [Global hotkeys (work even when minimised)](#14-global-hotkeys-work-even-when-minimised)
|
1. [Keyboard shortcuts (within the main window)](#14-keyboard-shortcuts-within-the-main-window)
|
||||||
1. [Remote control: adjusting a peer's listening volume from your end](#15-remote-control-adjusting-a-peers-listening-volume-from-your-end)
|
1. [Global hotkeys (work even when minimised)](#15-global-hotkeys-work-even-when-minimised)
|
||||||
1. [Startup behaviour](#16-startup-behaviour)
|
1. [Remote control: adjusting a peer's listening volume from your end](#16-remote-control-adjusting-a-peers-listening-volume-from-your-end)
|
||||||
1. [Audio cue sounds](#17-audio-cue-sounds)
|
1. [Startup behaviour](#17-startup-behaviour)
|
||||||
1. [Updating RemSound](#18-updating-remsound)
|
1. [Audio cue sounds](#18-audio-cue-sounds)
|
||||||
1. [Recording to a file](#19-recording-to-a-file)
|
1. [Updating RemSound](#19-updating-remsound)
|
||||||
1. [Logs and diagnostics](#20-logs-and-diagnostics)
|
1. [Recording to a file](#20-recording-to-a-file)
|
||||||
1. [Troubleshooting](#21-troubleshooting)
|
1. [Logs and diagnostics](#21-logs-and-diagnostics)
|
||||||
1. [Glossary](#22-glossary)
|
1. [Troubleshooting](#22-troubleshooting)
|
||||||
|
1. [Glossary](#23-glossary)
|
||||||
|
|
||||||
|
|
||||||
## 1. What RemSound does
|
## 1. What RemSound does
|
||||||
@@ -516,7 +517,43 @@ Round-trip time| What you'll experience
|
|||||||
50–100 ms| Tailscale via a relay, or one end on Wi-Fi a long way off. Still usable, but you start to notice it for music.
|
50–100 ms| Tailscale via a relay, or one end on Wi-Fi a long way off. Still usable, but you start to notice it for music.
|
||||||
100 ms+| Something is wrong, or you're talking across the world. Playing music together is hard.
|
100 ms+| Something is wrong, or you're talking across the world. Playing music together is hard.
|
||||||
|
|
||||||
## 12. Latency and audio quality
|
## 12. Passwords and encryption
|
||||||
|
|
||||||
|
From v3.3, **all the audio RemSound sends is encrypted** — scrambled as it leaves your computer and only unscrambled at the other end. Anyone in between (your internet provider, a shared Wi-Fi, anyone watching the connection) just sees noise. This means you no longer need a VPN simply to keep your audio private. And it adds no delay you could ever notice — the scrambling happens in millionths of a second, far less time than the audio itself takes.
|
||||||
|
|
||||||
|
### How it works: a password per profile
|
||||||
|
|
||||||
|
Every profile carries a **password** , and that password is the key. The rule is simple:
|
||||||
|
|
||||||
|
* **Same password on both ends →** you connect and hear each other.
|
||||||
|
* **Different passwords →** no audio passes, and RemSound tells you so (see below) rather than leaving you with mysterious silence.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
So the password does double duty: it both encrypts your audio and decides who you can talk to. You and the person you're connecting with simply agree a password — say it out loud, or text it to each other — and each set it on the profile you use to talk to one another. The profile names don't have to match; only the passwords do.
|
||||||
|
|
||||||
|
### Setting and changing passwords
|
||||||
|
|
||||||
|
Where| What it does
|
||||||
|
---|---
|
||||||
|
**When you create a profile**| Saving a new profile (File → Save as) asks you for a password right then.
|
||||||
|
**File → Change this profile's password** (Alt+F, P)| Changes the password on the profile you're using now. The box shows the current password in plain, readable text — so a screen reader reads the actual characters, not a row of dots — and you type a new one over it.
|
||||||
|
**Options → Profile passwords**| A list of every profile with its password in an editable box: a one-stop password manager. Edit any of them and press OK to save them all.
|
||||||
|
|
||||||
|
If you try to start sending or receiving on a profile that has no password yet, RemSound asks you to set one first (and offers to remember it on the profile so you don't type it again next time). Audio can't flow without a password — encryption is always on, there's no “off” switch.
|
||||||
|
|
||||||
|
### When passwords don't match
|
||||||
|
|
||||||
|
If you connect to someone whose password is different from yours, RemSound shows a clear message — _“ You and [name] have different passwords, so no audio will pass between you”_ — so you know exactly what to fix. If the other person is on an older version of RemSound that can't encrypt, you'll be told they need to update.
|
||||||
|
|
||||||
|
### Two things worth knowing
|
||||||
|
|
||||||
|
* **Everyone needs v3.3 or newer.** Because the audio is now scrambled, a v3.3 copy can only talk to other v3.3 (and later) copies. Anyone you connect with needs to update too.
|
||||||
|
* **The password lives with the profile.** It's stored (lightly scrambled) inside the profile file, so it travels with the profile if you copy it to another machine or sync it through something like Dropbox. That's handy, but it means you should keep the profile file private — protect it the way you'd protect the password itself.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 13. Latency and audio quality
|
||||||
|
|
||||||
Latency is the small delay between sound leaving one computer and arriving at the other. Five controls together shape the trade-off between latency and sound quality, all on the Audio profile tab:
|
Latency is the small delay between sound leaving one computer and arriving at the other. Five controls together shape the trade-off between latency and sound quality, all on the Audio profile tab:
|
||||||
|
|
||||||
@@ -615,7 +652,7 @@ PCM gives the very best sound with no quality loss at all, but it uses about ten
|
|||||||
|
|
||||||
Both Opus choices can automatically repair a single missing packet (see the section just above), so single drops are inaudible on both. PCM doesn't have that ability.
|
Both Opus choices can automatically repair a single missing packet (see the section just above), so single drops are inaudible on both. PCM doesn't have that ability.
|
||||||
|
|
||||||
## 13. Keyboard shortcuts (within the main window)
|
## 14. Keyboard shortcuts (within the main window)
|
||||||
|
|
||||||
Each tab has its own Alt+letter shortcuts. The same letter can do different things on different tabs without clashing — the shortcuts only work on the tab that's showing. Move between tabs with Ctrl+Tab and Ctrl+Shift+Tab.
|
Each tab has its own Alt+letter shortcuts. The same letter can do different things on different tabs without clashing — the shortcuts only work on the tab that's showing. Move between tabs with Ctrl+Tab and Ctrl+Shift+Tab.
|
||||||
|
|
||||||
@@ -696,7 +733,7 @@ Spacebar| Tick or untick an item in any device list, or toggle the focused check
|
|||||||
Up / Down| Move between items in any list
|
Up / Down| Move between items in any list
|
||||||
Alt+F4| Close (the standard Windows shortcut)
|
Alt+F4| Close (the standard Windows shortcut)
|
||||||
|
|
||||||
## 14. Global hotkeys (work even when minimised)
|
## 15. Global hotkeys (work even when minimised)
|
||||||
|
|
||||||
You set these up in the Keyboard shortcuts dialog (Ctrl+K, or Options → Keyboard shortcuts). The dialog is a single list of every hotkey you can set: **Enter** sets the highlighted row, **Del** clears it (back to _not set_), and **Escape** or the Close button closes the dialog. The defaults:
|
You set these up in the Keyboard shortcuts dialog (Ctrl+K, or Options → Keyboard shortcuts). The dialog is a single list of every hotkey you can set: **Enter** sets the highlighted row, **Del** clears it (back to _not set_), and **Escape** or the Close button closes the dialog. The defaults:
|
||||||
|
|
||||||
@@ -716,7 +753,7 @@ Send Windows global mute toggle to peers| Tell every connected peer to toggle th
|
|||||||
|
|
||||||
You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.
|
You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.
|
||||||
|
|
||||||
## 15. Remote control: adjusting a peer's listening volume from your end
|
## 16. Remote control: adjusting a peer's listening volume from your end
|
||||||
|
|
||||||
Here's the situation this is for: you're on your laptop, listening to sound coming from your desktop, and you've got NVDA Remote open so you can drive the desktop using your laptop's keyboard. Every key you press goes to the desktop — including any volume key on the laptop, which now never reaches the laptop itself. There's no way from inside that NVDA Remote session to nudge the laptop's listening volume without breaking out of the session.
|
Here's the situation this is for: you're on your laptop, listening to sound coming from your desktop, and you've got NVDA Remote open so you can drive the desktop using your laptop's keyboard. Every key you press goes to the desktop — including any volume key on the laptop, which now never reaches the laptop itself. There's no way from inside that NVDA Remote session to nudge the laptop's listening volume without breaking out of the session.
|
||||||
|
|
||||||
@@ -763,7 +800,7 @@ The feature is symmetric: both computers can both send and accept. If you set up
|
|||||||
|
|
||||||
> **Tip for troubleshooting:** the log file (Preferences dialog → Enable logs) records every remote-control command sent and received, including `IGNORED` entries when an incoming command was turned down — either because the sender wasn't in your list of ticked peers, or because “Accept remote volume commands” was off. Handy for working out “why isn't my hotkey doing anything” without guessing.
|
> **Tip for troubleshooting:** the log file (Preferences dialog → Enable logs) records every remote-control command sent and received, including `IGNORED` entries when an incoming command was turned down — either because the sender wasn't in your list of ticked peers, or because “Accept remote volume commands” was off. Handy for working out “why isn't my hotkey doing anything” without guessing.
|
||||||
|
|
||||||
## 16. Startup behaviour
|
## 17. Startup behaviour
|
||||||
|
|
||||||
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, and a Close button. Esc closes the dialog. Each tick is saved straight away — there's no OK or 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, and a Close button. Esc closes the dialog. Each tick is saved straight away — there's no OK or Apply button.
|
||||||
|
|
||||||
@@ -785,7 +822,7 @@ Toggle| What it does
|
|||||||
|
|
||||||
> **Where these are stored:** the start-minimised choice and the start-with-profile name are kept in a small settings file on this computer. The auto-start toggle is kept in Windows' standard startup list — you turn it on or off from this dialog, or from Task Manager → Startup.
|
> **Where these are stored:** the start-minimised choice and the start-with-profile name are kept in a small settings file on this computer. The auto-start toggle is kept in Windows' standard startup list — you turn it on or off from this dialog, or from Task Manager → Startup.
|
||||||
|
|
||||||
## 17. Audio cue sounds
|
## 18. Audio cue sounds
|
||||||
|
|
||||||
RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called **cue sounds**. Seven events have a cue:
|
RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called **cue sounds**. Seven events have a cue:
|
||||||
|
|
||||||
@@ -843,7 +880,7 @@ If a cue's WAV file is missing — either the default file doesn't exist or a cu
|
|||||||
|
|
||||||
> **Tip for sound designers:** the defaults are deliberately short and simple so they stay out of the way. If you'd like the cues to feel more in-character with a particular profile, the custom-sound feature is designed for exactly that. Keep WAV files short (well under a second usually works best) so cues don't overlap with each other on a busy day.
|
> **Tip for sound designers:** the defaults are deliberately short and simple so they stay out of the way. If you'd like the cues to feel more in-character with a particular profile, the custom-sound feature is designed for exactly that. Keep WAV files short (well under a second usually works best) so cues don't overlap with each other on a busy day.
|
||||||
|
|
||||||
## 18. Updating RemSound
|
## 19. Updating RemSound
|
||||||
|
|
||||||
RemSound can check for a newer version on a schedule you choose, prompt you to install it, and either ask first or do it quietly. There's also a one-press “check now” button so you don't have to wait for the timer.
|
RemSound can check for a newer version on a schedule you choose, prompt you to install it, and either ask first or do it quietly. There's also a one-press “check now” button so you don't have to wait for the timer.
|
||||||
|
|
||||||
@@ -857,6 +894,7 @@ Setting| Shortcut| What it does
|
|||||||
**Then check every** (drop-down)| Alt+U| How often RemSound checks for a newer version in the background _after_ launch. Choices: _Never_ , _Every hour_ , _Every 6 hours_ , _Every 24 hours_. The default is _Every 24 hours_. Your choice is remembered between launches; if you set it to _Never_ and you've also unticked the startup check, the only way an update arrives is through the manual button below.
|
**Then check every** (drop-down)| Alt+U| How often RemSound checks for a newer version in the background _after_ launch. Choices: _Never_ , _Every hour_ , _Every 6 hours_ , _Every 24 hours_. The default is _Every 24 hours_. Your choice is remembered between launches; if you set it to _Never_ and you've also unticked the startup check, the only way an update arrives is through the manual button below.
|
||||||
**Check for updates now** (button)| Alt+N| Checks for a newer version straight away. If you're already up to date you get a small popup saying so. If there's a newer version, you get a confirmation dialog with the release notes and a Yes / No to install. The same button is in the Help menu (Alt+H, C).
|
**Check for updates now** (button)| Alt+N| Checks for a newer version straight away. If you're already up to date you get a small popup saying so. If there's a newer version, you get a confirmation dialog with the release notes and a Yes / No to install. The same button is in the Help menu (Alt+H, C).
|
||||||
**Silently install updates when available** (checkbox)| Alt+I| When ticked, the background and startup checks install any available update without asking — RemSound downloads it, closes briefly, swaps the files, and reopens itself. Off by default. The startup check shows a brief notice first so you can see what's about to happen (see below). The _manual_ “Check for updates now” button always asks first, no matter how this checkbox is set.
|
**Silently install updates when available** (checkbox)| Alt+I| When ticked, the background and startup checks install any available update without asking — RemSound downloads it, closes briefly, swaps the files, and reopens itself. Off by default. The startup check shows a brief notice first so you can see what's about to happen (see below). The _manual_ “Check for updates now” button always asks first, no matter how this checkbox is set.
|
||||||
|
**Show what's new after each update** (checkbox)| Alt+H| When ticked, the first time RemSound opens after an update has installed, it pops up the About box — which starts with the notes for the version you just got — so you can see what changed. Off by default. It only happens once per update, never on an ordinary restart, and never on a fresh install.
|
||||||
|
|
||||||
### The brief notice before a silent update installs
|
### The brief notice before a silent update installs
|
||||||
|
|
||||||
@@ -904,7 +942,7 @@ A step-by-step record of every helper run is kept in a helper log file in the in
|
|||||||
|
|
||||||
To see which version you're on without checking for updates, open **Help → About RemSound** (Alt+H, A). The dialog shows the version number and the release notes for the version you're running, in a scrollable read-only box. Close (or Esc) dismisses it.
|
To see which version you're on without checking for updates, open **Help → About RemSound** (Alt+H, A). The dialog shows the version number and the release notes for the version you're running, in a scrollable read-only box. Close (or Esc) dismisses it.
|
||||||
|
|
||||||
## 19. Recording to a file
|
## 20. Recording to a file
|
||||||
|
|
||||||
RemSound can save the sound passing through it to a file on your computer — useful for keeping a copy of a music session, capturing a long jam for editing later, or just saving a one-off voice exchange you want to come back to.
|
RemSound can save the sound passing through it to a file on your computer — useful for keeping a copy of a music session, capturing a long jam for editing later, or just saving a one-off voice exchange you want to come back to.
|
||||||
|
|
||||||
@@ -970,7 +1008,7 @@ List| Shortcut| What goes in it
|
|||||||
|
|
||||||
OK (Alt+O) saves your choices to the current profile. Cancel (Alt+N) or Esc discards them. Settings are saved with the profile as usual — changes here mark the profile as having unsaved changes, and you'll be asked about them on exit if you haven't saved.
|
OK (Alt+O) saves your choices to the current profile. Cancel (Alt+N) or Esc discards them. Settings are saved with the profile as usual — changes here mark the profile as having unsaved changes, and you'll be asked about them on exit if you haven't saved.
|
||||||
|
|
||||||
## 20. Logs and diagnostics
|
## 21. Logs and diagnostics
|
||||||
|
|
||||||
If logging is turned on (the **Enable logs** checkbox in the Preferences dialog — Options → Preferences, or Ctrl+P — on by default), RemSound writes a log file each session into a `logs` folder next to the RemSound program. One file per launch.
|
If logging is turned on (the **Enable logs** checkbox in the Preferences dialog — Options → Preferences, or Ctrl+P — on by default), RemSound writes a log file each session into a `logs` folder next to the RemSound program. One file per launch.
|
||||||
|
|
||||||
@@ -994,7 +1032,7 @@ Logs are plain text and can be opened in any text editor, or in a spreadsheet. T
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 21. Troubleshooting
|
## 22. Troubleshooting
|
||||||
|
|
||||||
### I don't hear my friend
|
### I don't hear my friend
|
||||||
|
|
||||||
@@ -1077,7 +1115,7 @@ The most common reasons:
|
|||||||
|
|
||||||
If none of those apply, just fall back to Tailscale — it works without involving the router at all.
|
If none of those apply, just fall back to Tailscale — it works without involving the router at all.
|
||||||
|
|
||||||
## 22. Glossary
|
## 23. Glossary
|
||||||
|
|
||||||
Term| Meaning
|
Term| Meaning
|
||||||
---|---
|
---|---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# RemSound
|
# RemSound
|
||||||
|
|
||||||
**Free Windows app for sending live audio between two computers — across a house, across a city, or anywhere your internet reaches. Low delay, great quality, fully accessible to screen-reader users.**
|
**Free Windows app for sending live audio between two computers — across a house, across a city, or anywhere your internet reaches. Encrypted end to end, low delay, great quality, fully accessible to screen-reader users.**
|
||||||
|
|
||||||
[**Download the latest version**](https://github.com/Ednunp/RemSound/releases/latest) · [**Read the user manual**](MANUAL.md)
|
[**Download the latest version**](https://github.com/Ednunp/RemSound/releases/latest) · [**Read the user manual**](MANUAL.md)
|
||||||
|
|
||||||
@@ -12,6 +12,12 @@ You sit at one computer, RemSound captures whatever is playing — a track in yo
|
|||||||
|
|
||||||
It's also fully accessible. The interface was designed with screen readers (NVDA in particular) in mind from day one. Every button has a keyboard shortcut, every status line is read out clearly, and there are no menus or controls that need a mouse to reach.
|
It's also fully accessible. The interface was designed with screen readers (NVDA in particular) in mind from day one. Every button has a keyboard shortcut, every status line is read out clearly, and there are no menus or controls that need a mouse to reach.
|
||||||
|
|
||||||
|
## Private by default — your audio is encrypted
|
||||||
|
|
||||||
|
Everything RemSound sends is **encrypted end to end**: scrambled the moment it leaves your computer and only unscrambled at the other end, so nobody in between — your internet provider, a shared Wi-Fi, anyone watching the line — can listen in. You no longer need a VPN just to keep a private connection private.
|
||||||
|
|
||||||
|
It works with a simple shared password. You and the person you're connecting to use the **same password**, and only the two of you can hear the audio — get the password wrong and nothing comes through. RemSound stores a password on each profile and walks you through setting one. And it all adds no delay you'd ever notice.
|
||||||
|
|
||||||
## What you can do with it
|
## What you can do with it
|
||||||
|
|
||||||
* **Listen to one of your computers from another room.** Sit at your laptop and hear what's playing on your desktop. Walk around the house — the sound follows you.
|
* **Listen to one of your computers from another room.** Sit at your laptop and hear what's playing on your desktop. Walk around the house — the sound follows you.
|
||||||
@@ -30,7 +36,7 @@ Inside RemSound there's just one main decision: which quality and delay you want
|
|||||||
## How to install it
|
## How to install it
|
||||||
|
|
||||||
1. Go to the [latest release](https://github.com/Ednunp/RemSound/releases/latest).
|
1. Go to the [latest release](https://github.com/Ednunp/RemSound/releases/latest).
|
||||||
2. Download the file called `RemSound-v3.1.zip` (the version number changes over time — pick whichever is newest).
|
2. Download the file called `RemSound-v3.3.zip` (the version number changes over time — pick whichever is newest).
|
||||||
3. Extract the zip into a folder of your choice.
|
3. Extract the zip into a folder of your choice.
|
||||||
4. Double-click `RemSound.exe` and away you go.
|
4. Double-click `RemSound.exe` and away you go.
|
||||||
|
|
||||||
|
|||||||
+22
-19
@@ -1,41 +1,44 @@
|
|||||||
# RemSound v3.2
|
# RemSound v3.3
|
||||||
|
|
||||||
A new audio cue plus a round of reliability work. The headline is a sound that warns you an update is about to install — and underneath it, RemSound now flatly refuses to run as two copies at once.
|
The big one: **your audio is now encrypted end to end.** Plus the connect/disconnect cues are fixed, and a handful of smaller reliability improvements.
|
||||||
|
|
||||||
## New: an "Update sound" cue
|
## Your audio is now encrypted
|
||||||
|
|
||||||
RemSound now plays a short sound **just before it closes to install an update** — whether you started the update by hand or it installed silently in the background. So a silent update no longer catches you off guard: you hear that RemSound is about to restart.
|
Everything RemSound sends is now scrambled as it leaves your computer and only unscrambled at the other end — so nobody in between can listen in, and you no longer need a VPN just to keep a private connection private.
|
||||||
|
|
||||||
Like every other cue, it's **per-profile**, you can **mute** it, and you can **swap in your own WAV** — all from **File → Preferences → Audio cue sounds**, where it's the new seventh entry in the list. Preview it with the Play button, point it at your own sound with Browse, or right-click Browse to go back to the default.
|
It works with a password on each profile:
|
||||||
|
|
||||||
## Only one copy of RemSound at a time
|
- **You and the person you connect to use the same password** → you hear each other.
|
||||||
|
- **Different passwords** → no audio passes, and RemSound tells you so plainly instead of leaving you with silent confusion.
|
||||||
|
|
||||||
RemSound now refuses to run as two copies at once. If you open it while it's already running, it asks what you'd like to do:
|
Setting a password is easy: RemSound asks for one when you create a profile, you can change it any time with **File → Change this profile's password**, and you can see and edit the passwords for all your profiles in one place under **Options → Profile passwords**. If you start sending or receiving on a profile with no password, RemSound asks you to set one first. The encryption adds no delay you could ever notice.
|
||||||
|
|
||||||
- **Switch to the copy that's already running** — brings it back to the front (even if it was minimised to the system tray, down by the clock).
|
### Important: everyone needs v3.3
|
||||||
- **Force the running copy to close and start fresh** — for when a copy is stuck or not responding.
|
|
||||||
|
|
||||||
This closes off a problem where, after an update, RemSound could end up with several copies running at once, each playing audio and getting louder — the only way out being to force-close them all.
|
Because the audio format changed to carry the encryption, **a v3.3 copy can only talk to other v3.3 (and later) copies.** Anyone you connect with needs to update to v3.3 too. If you try to connect to an older copy, RemSound will tell you they need to update.
|
||||||
|
|
||||||
## Updates are more dependable
|
## Connect and disconnect cues, fixed
|
||||||
|
|
||||||
- **Nothing can interrupt an update's restart any more.** Previously an "unsaved changes?" question could pop up at the wrong moment and quietly cancel it.
|
- **Cues now play reliably, whatever the WAV format.** The old sound player couldn't handle high-resolution (24-bit / 96 kHz) files and would play them only sometimes — so cues, and the Preview button, were hit-and-miss. They now play every time, including any custom sound you supply.
|
||||||
- **A locked (read-only) profile stays locked when you save it.** Before, deliberately saving a locked profile silently dropped the lock — which then brought the save question back and could trip up an update.
|
- **The connect/disconnect cues now follow the actual audio**, not just the background "are you there?" heartbeat. So you won't hear a false "disconnect" while the sound is still playing, and the connect ding lands when the audio actually starts.
|
||||||
- **A single copy can't kick off two updates at once.**
|
|
||||||
|
|
||||||
## Nothing else has changed
|
## Smaller improvements
|
||||||
|
|
||||||
Same wire format, same codec list, same audio cues (plus the new one), same everything else from v3.1. v3.2 talks to v3.0.x and v3.1.x peers exactly as before.
|
- **No more crackle from address-hopping.** On setups where a peer is reachable two ways at once (e.g. a VPN and a local network), RemSound now sticks to the address that's actually working instead of flip-flopping between them.
|
||||||
|
- **Honest "online/offline".** A peer is no longer shown "offline" just because a discovery beacon blinked — only when it's genuinely gone by every measure.
|
||||||
|
- **See what's new after an update.** RemSound now opens its About box once after each update so you can see what changed. On by default; turn it off in Preferences.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
1. Download `RemSound-v3.2.zip` from this release.
|
1. Download `RemSound-v3.3.zip` from this release.
|
||||||
2. Close RemSound.
|
2. Close RemSound.
|
||||||
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your profiles, settings or recordings.
|
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your profiles, settings or recordings.
|
||||||
4. Run `RemSound.exe`.
|
4. Run `RemSound.exe`.
|
||||||
|
|
||||||
|
After updating, set a password on the profile(s) you use to connect (RemSound will prompt you), and make sure the people you connect with have also updated to v3.3 and are using the same password.
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
**v1.9 through v3.1.3:** Help → Check for updates works — it will fetch and install v3.2 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.2 installs itself shortly after launch (and you'll hear the new update cue as it does).
|
**v1.9 through v3.2:** Help → Check for updates works — it will fetch and install v3.3 automatically. If you've ticked "Check for updates on startup" and "Silently install updates", v3.3 installs itself shortly after launch.
|
||||||
|
|
||||||
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it from installing updates, so Check for updates will download v3.2 but not apply it. Install v3.2 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
|
**v1.8 and earlier:** the auto-updater in those versions has a fault that prevents it installing updates, so Check for updates will download v3.3 but not apply it. Install v3.3 by hand using the steps above — just this once. From the build you install onward, updates are automatic.
|
||||||
|
|||||||
+50
-11
@@ -38,6 +38,7 @@ ul, ol { padding-left: 1.4em; }
|
|||||||
<li><a href="#asio-vs-wasapi">ASIO and WASAPI</a></li>
|
<li><a href="#asio-vs-wasapi">ASIO and WASAPI</a></li>
|
||||||
<li><a href="#peers">Peers — finding and connecting</a></li>
|
<li><a href="#peers">Peers — finding and connecting</a></li>
|
||||||
<li><a href="#network">How the network works (LAN, WAN, Tailscale)</a></li>
|
<li><a href="#network">How the network works (LAN, WAN, Tailscale)</a></li>
|
||||||
|
<li><a href="#encryption">Passwords and encryption</a></li>
|
||||||
<li><a href="#latency">Latency and audio quality</a></li>
|
<li><a href="#latency">Latency and audio quality</a></li>
|
||||||
<li><a href="#shortcuts">Keyboard shortcuts</a></li>
|
<li><a href="#shortcuts">Keyboard shortcuts</a></li>
|
||||||
<li><a href="#hotkeys">Global hotkeys (mute, volume, tray, recording)</a></li>
|
<li><a href="#hotkeys">Global hotkeys (mute, volume, tray, recording)</a></li>
|
||||||
@@ -549,7 +550,44 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
|
|||||||
<tr><td>100 ms+</td><td>Something is wrong, or you're talking across the world. Playing music together is hard.</td></tr>
|
<tr><td>100 ms+</td><td>Something is wrong, or you're talking across the world. Playing music together is hard.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h2 id="latency">12. Latency and audio quality</h2>
|
<h2 id="encryption">12. Passwords and encryption</h2>
|
||||||
|
|
||||||
|
<p>From v3.3, <strong>all the audio RemSound sends is encrypted</strong> — scrambled as it leaves your computer and only unscrambled at the other end. Anyone in between (your internet provider, a shared Wi-Fi, anyone watching the connection) just sees noise. This means you no longer need a VPN simply to keep your audio private. And it adds no delay you could ever notice — the scrambling happens in millionths of a second, far less time than the audio itself takes.</p>
|
||||||
|
|
||||||
|
<h3>How it works: a password per profile</h3>
|
||||||
|
|
||||||
|
<p>Every profile carries a <strong>password</strong>, and that password is the key. The rule is simple:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Same password on both ends →</strong> you connect and hear each other.</li>
|
||||||
|
<li><strong>Different passwords →</strong> no audio passes, and RemSound tells you so (see below) rather than leaving you with mysterious silence.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>So the password does double duty: it both encrypts your audio and decides who you can talk to. You and the person you're connecting with simply agree a password — say it out loud, or text it to each other — and each set it on the profile you use to talk to one another. The profile names don't have to match; only the passwords do.</p>
|
||||||
|
|
||||||
|
<h3>Setting and changing passwords</h3>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>Where</th><th>What it does</th></tr>
|
||||||
|
<tr><td><strong>When you create a profile</strong></td><td>Saving a new profile (File → Save as) asks you for a password right then.</td></tr>
|
||||||
|
<tr><td><strong>File → Change this profile's password</strong> (Alt+F, P)</td><td>Changes the password on the profile you're using now. The box shows the current password in plain, readable text — so a screen reader reads the actual characters, not a row of dots — and you type a new one over it.</td></tr>
|
||||||
|
<tr><td><strong>Options → Profile passwords</strong></td><td>A list of every profile with its password in an editable box: a one-stop password manager. Edit any of them and press OK to save them all.</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>If you try to start sending or receiving on a profile that has no password yet, RemSound asks you to set one first (and offers to remember it on the profile so you don't type it again next time). Audio can't flow without a password — encryption is always on, there's no “off” switch.</p>
|
||||||
|
|
||||||
|
<h3>When passwords don't match</h3>
|
||||||
|
|
||||||
|
<p>If you connect to someone whose password is different from yours, RemSound shows a clear message — <em>“You and [name] have different passwords, so no audio will pass between you”</em> — so you know exactly what to fix. If the other person is on an older version of RemSound that can't encrypt, you'll be told they need to update.</p>
|
||||||
|
|
||||||
|
<h3>Two things worth knowing</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Everyone needs v3.3 or newer.</strong> Because the audio is now scrambled, a v3.3 copy can only talk to other v3.3 (and later) copies. Anyone you connect with needs to update too.</li>
|
||||||
|
<li><strong>The password lives with the profile.</strong> It's stored (lightly scrambled) inside the profile file, so it travels with the profile if you copy it to another machine or sync it through something like Dropbox. That's handy, but it means you should keep the profile file private — protect it the way you'd protect the password itself.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="latency">13. Latency and audio quality</h2>
|
||||||
|
|
||||||
<p>Latency is the small delay between sound leaving one computer and arriving at the other. Five controls together shape the trade-off between latency and sound quality, all on the Audio profile tab:</p>
|
<p>Latency is the small delay between sound leaving one computer and arriving at the other. Five controls together shape the trade-off between latency and sound quality, all on the Audio profile tab:</p>
|
||||||
|
|
||||||
@@ -653,7 +691,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
|
|||||||
|
|
||||||
<p>Both Opus choices can automatically repair a single missing packet (see the section just above), so single drops are inaudible on both. PCM doesn't have that ability.</p>
|
<p>Both Opus choices can automatically repair a single missing packet (see the section just above), so single drops are inaudible on both. PCM doesn't have that ability.</p>
|
||||||
|
|
||||||
<h2 id="shortcuts">13. Keyboard shortcuts (within the main window)</h2>
|
<h2 id="shortcuts">14. Keyboard shortcuts (within the main window)</h2>
|
||||||
|
|
||||||
<p>Each tab has its own Alt+letter shortcuts. The same letter can do different things on different tabs without clashing — the shortcuts only work on the tab that's showing. Move between tabs with Ctrl+Tab and Ctrl+Shift+Tab.</p>
|
<p>Each tab has its own Alt+letter shortcuts. The same letter can do different things on different tabs without clashing — the shortcuts only work on the tab that's showing. Move between tabs with Ctrl+Tab and Ctrl+Shift+Tab.</p>
|
||||||
|
|
||||||
@@ -735,7 +773,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
|
|||||||
<tr><td>Alt+F4</td><td>Close (the standard Windows shortcut)</td></tr>
|
<tr><td>Alt+F4</td><td>Close (the standard Windows shortcut)</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h2 id="hotkeys">14. Global hotkeys (work even when minimised)</h2>
|
<h2 id="hotkeys">15. Global hotkeys (work even when minimised)</h2>
|
||||||
|
|
||||||
<p>You set these up in the Keyboard shortcuts dialog (Ctrl+K, or Options → Keyboard shortcuts). The dialog is a single list of every hotkey you can set: <strong>Enter</strong> sets the highlighted row, <strong>Del</strong> clears it (back to <em>not set</em>), and <strong>Escape</strong> or the Close button closes the dialog. The defaults:</p>
|
<p>You set these up in the Keyboard shortcuts dialog (Ctrl+K, or Options → Keyboard shortcuts). The dialog is a single list of every hotkey you can set: <strong>Enter</strong> sets the highlighted row, <strong>Del</strong> clears it (back to <em>not set</em>), and <strong>Escape</strong> or the Close button closes the dialog. The defaults:</p>
|
||||||
|
|
||||||
@@ -756,7 +794,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L
|
|||||||
|
|
||||||
<p>You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.</p>
|
<p>You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.</p>
|
||||||
|
|
||||||
<h2 id="remote-control">15. Remote control: adjusting a peer's listening volume from your end</h2>
|
<h2 id="remote-control">16. Remote control: adjusting a peer's listening volume from your end</h2>
|
||||||
|
|
||||||
<p>Here's the situation this is for: you're on your laptop, listening to sound coming from your desktop, and you've got NVDA Remote open so you can drive the desktop using your laptop's keyboard. Every key you press goes to the desktop — including any volume key on the laptop, which now never reaches the laptop itself. There's no way from inside that NVDA Remote session to nudge the laptop's listening volume without breaking out of the session.</p>
|
<p>Here's the situation this is for: you're on your laptop, listening to sound coming from your desktop, and you've got NVDA Remote open so you can drive the desktop using your laptop's keyboard. Every key you press goes to the desktop — including any volume key on the laptop, which now never reaches the laptop itself. There's no way from inside that NVDA Remote session to nudge the laptop's listening volume without breaking out of the session.</p>
|
||||||
|
|
||||||
@@ -810,7 +848,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
<strong>Tip for troubleshooting:</strong> the log file (Preferences dialog → Enable logs) records every remote-control command sent and received, including <code>IGNORED</code> entries when an incoming command was turned down — either because the sender wasn't in your list of ticked peers, or because “Accept remote volume commands” was off. Handy for working out “why isn't my hotkey doing anything” without guessing.
|
<strong>Tip for troubleshooting:</strong> the log file (Preferences dialog → Enable logs) records every remote-control command sent and received, including <code>IGNORED</code> entries when an incoming command was turned down — either because the sender wasn't in your list of ticked peers, or because “Accept remote volume commands” was off. Handy for working out “why isn't my hotkey doing anything” without guessing.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 id="startup">16. Startup behaviour</h2>
|
<h2 id="startup">17. Startup behaviour</h2>
|
||||||
|
|
||||||
<p>Open the <strong>Startup behaviour</strong> 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, and a Close button. Esc closes the dialog. Each tick is saved straight away — there's no OK or Apply button.</p>
|
<p>Open the <strong>Startup behaviour</strong> 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, and a Close button. Esc closes the dialog. Each tick is saved straight away — there's no OK or Apply button.</p>
|
||||||
|
|
||||||
@@ -835,7 +873,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
<strong>Where these are stored:</strong> the start-minimised choice and the start-with-profile name are kept in a small settings file on this computer. The auto-start toggle is kept in Windows' standard startup list — you turn it on or off from this dialog, or from Task Manager → Startup.
|
<strong>Where these are stored:</strong> the start-minimised choice and the start-with-profile name are kept in a small settings file on this computer. The auto-start toggle is kept in Windows' standard startup list — you turn it on or off from this dialog, or from Task Manager → Startup.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 id="audio-cues">17. Audio cue sounds</h2>
|
<h2 id="audio-cues">18. Audio cue sounds</h2>
|
||||||
|
|
||||||
<p>RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called <strong>cue sounds</strong>. Seven events have a cue:</p>
|
<p>RemSound plays a short sound at moments where you might want an audible confirmation that something just happened. These are called <strong>cue sounds</strong>. Seven events have a cue:</p>
|
||||||
|
|
||||||
@@ -896,7 +934,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
<strong>Tip for sound designers:</strong> the defaults are deliberately short and simple so they stay out of the way. If you'd like the cues to feel more in-character with a particular profile, the custom-sound feature is designed for exactly that. Keep WAV files short (well under a second usually works best) so cues don't overlap with each other on a busy day.
|
<strong>Tip for sound designers:</strong> the defaults are deliberately short and simple so they stay out of the way. If you'd like the cues to feel more in-character with a particular profile, the custom-sound feature is designed for exactly that. Keep WAV files short (well under a second usually works best) so cues don't overlap with each other on a busy day.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 id="updates">18. Updating RemSound</h2>
|
<h2 id="updates">19. Updating RemSound</h2>
|
||||||
|
|
||||||
<p>RemSound can check for a newer version on a schedule you choose, prompt you to install it, and either ask first or do it quietly. There's also a one-press “check now” button so you don't have to wait for the timer.</p>
|
<p>RemSound can check for a newer version on a schedule you choose, prompt you to install it, and either ask first or do it quietly. There's also a one-press “check now” button so you don't have to wait for the timer.</p>
|
||||||
|
|
||||||
@@ -910,6 +948,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
<tr><td><strong>Then check every</strong> (drop-down)</td><td>Alt+U</td><td>How often RemSound checks for a newer version in the background <em>after</em> launch. Choices: <em>Never</em>, <em>Every hour</em>, <em>Every 6 hours</em>, <em>Every 24 hours</em>. The default is <em>Every 24 hours</em>. Your choice is remembered between launches; if you set it to <em>Never</em> and you've also unticked the startup check, the only way an update arrives is through the manual button below.</td></tr>
|
<tr><td><strong>Then check every</strong> (drop-down)</td><td>Alt+U</td><td>How often RemSound checks for a newer version in the background <em>after</em> launch. Choices: <em>Never</em>, <em>Every hour</em>, <em>Every 6 hours</em>, <em>Every 24 hours</em>. The default is <em>Every 24 hours</em>. Your choice is remembered between launches; if you set it to <em>Never</em> and you've also unticked the startup check, the only way an update arrives is through the manual button below.</td></tr>
|
||||||
<tr><td><strong>Check for updates now</strong> (button)</td><td>Alt+N</td><td>Checks for a newer version straight away. If you're already up to date you get a small popup saying so. If there's a newer version, you get a confirmation dialog with the release notes and a Yes / No to install. The same button is in the Help menu (Alt+H, C).</td></tr>
|
<tr><td><strong>Check for updates now</strong> (button)</td><td>Alt+N</td><td>Checks for a newer version straight away. If you're already up to date you get a small popup saying so. If there's a newer version, you get a confirmation dialog with the release notes and a Yes / No to install. The same button is in the Help menu (Alt+H, C).</td></tr>
|
||||||
<tr><td><strong>Silently install updates when available</strong> (checkbox)</td><td>Alt+I</td><td>When ticked, the background and startup checks install any available update without asking — RemSound downloads it, closes briefly, swaps the files, and reopens itself. Off by default. The startup check shows a brief notice first so you can see what's about to happen (see below). The <em>manual</em> “Check for updates now” button always asks first, no matter how this checkbox is set.</td></tr>
|
<tr><td><strong>Silently install updates when available</strong> (checkbox)</td><td>Alt+I</td><td>When ticked, the background and startup checks install any available update without asking — RemSound downloads it, closes briefly, swaps the files, and reopens itself. Off by default. The startup check shows a brief notice first so you can see what's about to happen (see below). The <em>manual</em> “Check for updates now” button always asks first, no matter how this checkbox is set.</td></tr>
|
||||||
|
<tr><td><strong>Show what's new after each update</strong> (checkbox)</td><td>Alt+H</td><td>When ticked, the first time RemSound opens after an update has installed, it pops up the About box — which starts with the notes for the version you just got — so you can see what changed. Off by default. It only happens once per update, never on an ordinary restart, and never on a fresh install.</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>The brief notice before a silent update installs</h3>
|
<h3>The brief notice before a silent update installs</h3>
|
||||||
@@ -958,7 +997,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
|
|
||||||
<p>To see which version you're on without checking for updates, open <strong>Help → About RemSound</strong> (Alt+H, A). The dialog shows the version number and the release notes for the version you're running, in a scrollable read-only box. Close (or Esc) dismisses it.</p>
|
<p>To see which version you're on without checking for updates, open <strong>Help → About RemSound</strong> (Alt+H, A). The dialog shows the version number and the release notes for the version you're running, in a scrollable read-only box. Close (or Esc) dismisses it.</p>
|
||||||
|
|
||||||
<h2 id="recording">19. Recording to a file</h2>
|
<h2 id="recording">20. Recording to a file</h2>
|
||||||
|
|
||||||
<p>RemSound can save the sound passing through it to a file on your computer — useful for keeping a copy of a music session, capturing a long jam for editing later, or just saving a one-off voice exchange you want to come back to.</p>
|
<p>RemSound can save the sound passing through it to a file on your computer — useful for keeping a copy of a music session, capturing a long jam for editing later, or just saving a one-off voice exchange you want to come back to.</p>
|
||||||
|
|
||||||
@@ -1025,7 +1064,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
|
|
||||||
<p>OK (Alt+O) saves your choices to the current profile. Cancel (Alt+N) or Esc discards them. Settings are saved with the profile as usual — changes here mark the profile as having unsaved changes, and you'll be asked about them on exit if you haven't saved.</p>
|
<p>OK (Alt+O) saves your choices to the current profile. Cancel (Alt+N) or Esc discards them. Settings are saved with the profile as usual — changes here mark the profile as having unsaved changes, and you'll be asked about them on exit if you haven't saved.</p>
|
||||||
|
|
||||||
<h2 id="logs">20. Logs and diagnostics</h2>
|
<h2 id="logs">21. Logs and diagnostics</h2>
|
||||||
|
|
||||||
<p>If logging is turned on (the <strong>Enable logs</strong> checkbox in the Preferences dialog — Options → Preferences, or Ctrl+P — on by default), RemSound writes a log file each session into a <code>logs</code> folder next to the RemSound program. One file per launch.</p>
|
<p>If logging is turned on (the <strong>Enable logs</strong> checkbox in the Preferences dialog — Options → Preferences, or Ctrl+P — on by default), RemSound writes a log file each session into a <code>logs</code> folder next to the RemSound program. One file per launch.</p>
|
||||||
|
|
||||||
@@ -1049,7 +1088,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
<li><strong>OpusUnrecoveredGaps</strong> — a running total of multi-packet losses that Opus couldn't repair. Each one is an audible click. It stays at 0 on a clean connection; small numbers are normal over the internet.</li>
|
<li><strong>OpusUnrecoveredGaps</strong> — a running total of multi-packet losses that Opus couldn't repair. Each one is an audible click. It stays at 0 on a clean connection; small numbers are normal over the internet.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2 id="troubleshooting">21. Troubleshooting</h2>
|
<h2 id="troubleshooting">22. Troubleshooting</h2>
|
||||||
|
|
||||||
<h3>I don't hear my friend</h3>
|
<h3>I don't hear my friend</h3>
|
||||||
<ol>
|
<ol>
|
||||||
@@ -1118,7 +1157,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
|
|||||||
</ul>
|
</ul>
|
||||||
<p>If none of those apply, just fall back to Tailscale — it works without involving the router at all.</p>
|
<p>If none of those apply, just fall back to Tailscale — it works without involving the router at all.</p>
|
||||||
|
|
||||||
<h2 id="glossary">22. Glossary</h2>
|
<h2 id="glossary">23. Glossary</h2>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Term</th><th>Meaning</th></tr>
|
<tr><th>Term</th><th>Meaning</th></tr>
|
||||||
|
|||||||
@@ -20,6 +20,47 @@ internal sealed class AboutDialog : Form
|
|||||||
/// updates" path.</summary>
|
/// updates" path.</summary>
|
||||||
private const string ReleaseNotes =
|
private const string ReleaseNotes =
|
||||||
"""
|
"""
|
||||||
|
RemSound v3.3
|
||||||
|
|
||||||
|
Your audio is now encrypted, end to end, so you no
|
||||||
|
longer need a VPN just to keep it private.
|
||||||
|
|
||||||
|
How it works: every profile has a password. You and
|
||||||
|
the person you're connecting to must use the SAME
|
||||||
|
password — then your audio is scrambled on the way
|
||||||
|
out and only unscrambled at the other end. Anyone in
|
||||||
|
between hears nothing usable. The password is what
|
||||||
|
lets you connect: matching passwords connect, and
|
||||||
|
you hear each other; different passwords mean no
|
||||||
|
audio (RemSound tells you when that happens).
|
||||||
|
|
||||||
|
Setting a password: when you create a profile it asks
|
||||||
|
for one. You can change it any time with File → Change
|
||||||
|
this profile's password, and you can see and edit the
|
||||||
|
passwords for ALL your profiles in one place under
|
||||||
|
Options → Profile passwords. If you try to start
|
||||||
|
sending or receiving on a profile that has no password
|
||||||
|
yet, RemSound asks you to set one first.
|
||||||
|
|
||||||
|
Adds almost no delay — the scrambling takes millionths
|
||||||
|
of a second per packet, far less than the audio itself.
|
||||||
|
|
||||||
|
Important: because the audio format changed, v3.3 can
|
||||||
|
only talk to other v3.3 (and later) copies. Anyone you
|
||||||
|
connect with needs to update to v3.3 too.
|
||||||
|
|
||||||
|
Also in this release: connect, disconnect and the
|
||||||
|
other cue sounds now play reliably whatever format the
|
||||||
|
WAV is in (they used to be hit-and-miss with high-
|
||||||
|
resolution files); the connect/disconnect cues now
|
||||||
|
follow the actual audio, so you won't hear a false
|
||||||
|
"disconnect" while sound is still playing; the system
|
||||||
|
tray icon sticks to a peer's working address instead
|
||||||
|
of hopping between a VPN and a LAN address (which could
|
||||||
|
cause crackle on some setups); and RemSound now offers
|
||||||
|
to show you what's new after each update (on by
|
||||||
|
default; turn it off in Preferences).
|
||||||
|
|
||||||
RemSound v3.2
|
RemSound v3.2
|
||||||
|
|
||||||
A new audio cue, plus the reliability work from
|
A new audio cue, plus the reliability work from
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using NAudio.Wave;
|
||||||
|
using NAudio.Wave.SampleProviders;
|
||||||
|
|
||||||
|
namespace RemSound.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays a short cue WAV reliably, regardless of its format. Replaces System.Media.SoundPlayer,
|
||||||
|
/// which only handles bog-standard 16-bit / 44.1–48 kHz PCM and silently fails (plays nothing,
|
||||||
|
/// intermittently) on anything else — which is exactly why RemSound's own 96 kHz / 24-bit cue
|
||||||
|
/// files, and any custom WAV a user browses for, played unpredictably (2026-06-02 investigation
|
||||||
|
/// of Ed's "didn't hear the disconnect cue" report).
|
||||||
|
///
|
||||||
|
/// Each Play() reads the file with NAudio (which copes with 16/24/32-bit and any sample rate),
|
||||||
|
/// resamples to a universally-safe 48 kHz / 16-bit with a pure-managed resampler (no Media
|
||||||
|
/// Foundation, so it works on Windows 7 too), and renders it through WaveOut to the default
|
||||||
|
/// output device. Setup happens on a background thread so a cue never hitches the UI, and the
|
||||||
|
/// output + reader are disposed when playback finishes. Cues are occasional and short, so a
|
||||||
|
/// fresh device-open per play is fine and keeps the class stateless and overlap-safe (two cues
|
||||||
|
/// close together simply mix). 2026-06-02.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CuePlayer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string filePath;
|
||||||
|
|
||||||
|
public CuePlayer(string filePath) => this.filePath = filePath;
|
||||||
|
|
||||||
|
public void Play()
|
||||||
|
{
|
||||||
|
var path = filePath;
|
||||||
|
Task.Run(() =>
|
||||||
|
{
|
||||||
|
AudioFileReader? reader = null;
|
||||||
|
WaveOutEvent? output = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reader = new AudioFileReader(path);
|
||||||
|
ISampleProvider source = reader;
|
||||||
|
if (reader.WaveFormat.SampleRate != 48000)
|
||||||
|
{
|
||||||
|
source = new WdlResamplingSampleProvider(source, 48000);
|
||||||
|
}
|
||||||
|
output = new WaveOutEvent();
|
||||||
|
var capturedReader = reader;
|
||||||
|
var capturedOutput = output;
|
||||||
|
output.PlaybackStopped += (_, _) =>
|
||||||
|
{
|
||||||
|
try { capturedOutput.Dispose(); } catch { /* best-effort */ }
|
||||||
|
try { capturedReader.Dispose(); } catch { /* best-effort */ }
|
||||||
|
};
|
||||||
|
output.Init(new SampleToWaveProvider16(source));
|
||||||
|
output.Play();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// A cue that won't load or play must never disturb anything. Clean up and move on.
|
||||||
|
try { output?.Dispose(); } catch { /* ignore */ }
|
||||||
|
try { reader?.Dispose(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
// Nothing persistent to release — each Play owns and disposes its own reader + output.
|
||||||
|
}
|
||||||
|
}
|
||||||
+391
-37
@@ -307,26 +307,29 @@ public sealed class MainForm : Form
|
|||||||
// Last time TryAdoptLiveHeartbeatAddress re-pointed the sender at a peer's live address.
|
// Last time TryAdoptLiveHeartbeatAddress re-pointed the sender at a peer's live address.
|
||||||
// Gives a fresh endpoint time to prove healthy before another swap can fire (anti-thrash).
|
// Gives a fresh endpoint time to prove healthy before another swap can fire (anti-thrash).
|
||||||
private DateTime lastAddressAdoptionUtc = DateTime.MinValue;
|
private DateTime lastAddressAdoptionUtc = DateTime.MinValue;
|
||||||
// Tracks the most recent PeerHealthState we observed for each peer endpoint, so we can
|
// Tracks whether each peer was last considered CONNECTED, for the connect/disconnect cues.
|
||||||
// detect transitions and play the appropriate cue. Connect: any state → Healthy.
|
// "Connected" now means audio is actually flowing OR the heartbeat is healthy — not the
|
||||||
// Disconnect: any state → Unreachable. Stale doesn't fire (it's a transient).
|
// heartbeat alone (see DetectAndAnnouncePeerHealthTransitions). The bool, rather than the
|
||||||
private readonly Dictionary<string, PeerHealthState> previousPeerHealthStates = new(StringComparer.OrdinalIgnoreCase);
|
// raw health state, gives natural hysteresis: once connected we stay connected until audio
|
||||||
private System.Media.SoundPlayer? connectSound;
|
// genuinely stops AND the heartbeat goes unreachable, so a heartbeat blip while audio keeps
|
||||||
private System.Media.SoundPlayer? disconnectSound;
|
// playing never fires a false disconnect cue. 2026-05-31 rewrite.
|
||||||
|
private readonly Dictionary<string, bool> peerConnectedState = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private CuePlayer? connectSound;
|
||||||
|
private CuePlayer? disconnectSound;
|
||||||
// Recording start/stop cues. Played via SoundPlayer to the default Windows output —
|
// Recording start/stop cues. Played via SoundPlayer to the default Windows output —
|
||||||
// same path as connect/disconnect. They don't pass through our recording taps (those
|
// same path as connect/disconnect. They don't pass through our recording taps (those
|
||||||
// sit on the internal sender mix bus and receiver render path), so they don't appear
|
// sit on the internal sender mix bus and receiver render path), so they don't appear
|
||||||
// in normal recordings. A user who has a WASAPI loopback of the same output device as
|
// in normal recordings. A user who has a WASAPI loopback of the same output device as
|
||||||
// a capture source would still get them, but that's their loopback configuration, not
|
// a capture source would still get them, but that's their loopback configuration, not
|
||||||
// anything the recorder is doing.
|
// anything the recorder is doing.
|
||||||
private System.Media.SoundPlayer? recordStartSound;
|
private CuePlayer? recordStartSound;
|
||||||
private System.Media.SoundPlayer? recordStopSound;
|
private CuePlayer? recordStopSound;
|
||||||
// Profile-save and profile-switch cues, added 2026-05-28 alongside the move of all
|
// Profile-save and profile-switch cues, added 2026-05-28 alongside the move of all
|
||||||
// default WAVs into a sounds\ subfolder. Save fires after a successful File → Save /
|
// default WAVs into a sounds\ subfolder. Save fires after a successful File → Save /
|
||||||
// Save As; Profile fires immediately after a profile finishes loading in MainForm.
|
// Save As; Profile fires immediately after a profile finishes loading in MainForm.
|
||||||
private System.Media.SoundPlayer? saveSound;
|
private CuePlayer? saveSound;
|
||||||
private System.Media.SoundPlayer? profileSwitchSound;
|
private CuePlayer? profileSwitchSound;
|
||||||
private System.Media.SoundPlayer? updateSound;
|
private CuePlayer? updateSound;
|
||||||
// Labels for the three send/receive device lists, captured at layout time so they can be
|
// Labels for the three send/receive device lists, captured at layout time so they can be
|
||||||
// re-titled when the user toggles between WASAPI mode (Windows devices) and ASIO mode
|
// re-titled when the user toggles between WASAPI mode (Windows devices) and ASIO mode
|
||||||
// (driver channel pairs). null until BuildLayout has run.
|
// (driver channel pairs). null until BuildLayout has run.
|
||||||
@@ -458,6 +461,24 @@ public sealed class MainForm : Form
|
|||||||
// it shouldn't block shutdown when his screen reader can't reach the dirty-prompt.
|
// it shouldn't block shutdown when his screen reader can't reach the dirty-prompt.
|
||||||
// Toggled via File → Lock profile (read-only) and persisted on the profile JSON.
|
// Toggled via File → Lock profile (read-only) and persisted on the profile JSON.
|
||||||
private bool currentProfileReadOnly;
|
private bool currentProfileReadOnly;
|
||||||
|
// The active profile's encryption password, in PLAIN text (the profile JSON stores it
|
||||||
|
// lightly scrambled — see RemSoundCrypto.Obfuscate). "" = no password set. Two peers can
|
||||||
|
// exchange audio only when their profile passwords match. Set from the loaded profile in
|
||||||
|
// the constructor, changed via File → Change this profile's password, and carried back into
|
||||||
|
// every save by BuildCurrentProfile. 2026-05-31 (always-on encryption, in development).
|
||||||
|
private string currentProfilePassword = "";
|
||||||
|
// The AES key + fingerprint derived from currentProfilePassword, cached so the slow key
|
||||||
|
// derivation only runs when the password actually changes. Pushed down to the sender and
|
||||||
|
// receiver by RecomputeAudioCrypto. Null when no password is set (then no audio flows).
|
||||||
|
private byte[]? currentAudioKey;
|
||||||
|
private byte[]? currentAudioFingerprint;
|
||||||
|
private string? lastDerivedPassword;
|
||||||
|
// Re-entrancy guard for the "you need a password to stream" gate, so programmatically
|
||||||
|
// un-ticking the send/receive box (when the user cancels the password prompt) doesn't
|
||||||
|
// re-fire the gate. And a record of which peers we've already warned about a password
|
||||||
|
// mismatch / out-of-date version, so the warning shows once per change, not every second.
|
||||||
|
private bool suppressStreamingPasswordGate;
|
||||||
|
private readonly Dictionary<System.Net.IPAddress, PeerSecurityStatus> lastSecurityWarned = new();
|
||||||
// The actual menu item — kept as a field so profile-load (or read-only toggle) can
|
// The actual menu item — kept as a field so profile-load (or read-only toggle) can
|
||||||
// sync .Checked without rebuilding the menu. CheckOnClick lets the menu item flip
|
// sync .Checked without rebuilding the menu. CheckOnClick lets the menu item flip
|
||||||
// itself on every click; the CheckedChanged handler reads the new value and runs
|
// itself on every click; the CheckedChanged handler reads the new value and runs
|
||||||
@@ -568,6 +589,8 @@ public sealed class MainForm : Form
|
|||||||
// template (profile == null) implicitly starts as not-read-only; users still have
|
// template (profile == null) implicitly starts as not-read-only; users still have
|
||||||
// the menu toggle available if they want to lock the working state mid-session.
|
// the menu toggle available if they want to lock the working state mid-session.
|
||||||
currentProfileReadOnly = profile?.ReadOnly ?? false;
|
currentProfileReadOnly = profile?.ReadOnly ?? false;
|
||||||
|
// Unscramble the profile's stored password into the in-memory plain-text working value.
|
||||||
|
currentProfilePassword = RemSoundCrypto.Deobfuscate(profile?.Password);
|
||||||
// Push the profile's settings-shaped fields (codec, hotkeys, smoothness, etc.) into
|
// 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.
|
// the in-memory settings cache BEFORE the rest of the constructor body reads from it.
|
||||||
// Control states (device ticks, checkboxes, volume) come later in OnShown.
|
// Control states (device ticks, checkboxes, volume) come later in OnShown.
|
||||||
@@ -902,8 +925,8 @@ public sealed class MainForm : Form
|
|||||||
ApplyAsioMode();
|
ApplyAsioMode();
|
||||||
|
|
||||||
// --- Wire main-form events ---
|
// --- Wire main-form events ---
|
||||||
receiveAudioCheckbox.CheckedChanged += (_, _) => { HandleCapabilityChange(); MarkProfileDirty(); };
|
receiveAudioCheckbox.CheckedChanged += (_, _) => OnStreamingCheckboxChanged(receiveAudioCheckbox);
|
||||||
sendMyAudioCheckbox.CheckedChanged += (_, _) => { HandleCapabilityChange(); MarkProfileDirty(); };
|
sendMyAudioCheckbox.CheckedChanged += (_, _) => OnStreamingCheckboxChanged(sendMyAudioCheckbox);
|
||||||
volumeBar.Scroll += (_, _) => { receiver.Volume = volumeBar.Value / 100f; MarkProfileDirty(); };
|
volumeBar.Scroll += (_, _) => { receiver.Volume = volumeBar.Value / 100f; MarkProfileDirty(); };
|
||||||
WireCheckedListAccessibility(receiveOutputDevicesList, receiveOutputDevicesStatusLabel, "receive output device");
|
WireCheckedListAccessibility(receiveOutputDevicesList, receiveOutputDevicesStatusLabel, "receive output device");
|
||||||
receiveOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); } };
|
receiveOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); } };
|
||||||
@@ -1164,12 +1187,61 @@ public sealed class MainForm : Form
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the user opted in, show the About box once on the first launch after an update
|
||||||
|
// installed, so they see what's new. BeginInvoke so it opens after Shown completes.
|
||||||
|
BeginInvoke(new Action(MaybeShowWhatsNewAfterUpdate));
|
||||||
};
|
};
|
||||||
|
|
||||||
statusTimer.Start();
|
statusTimer.Start();
|
||||||
deviceRefreshTimer.Start();
|
deviceRefreshTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>If the user opted in (<see cref="AppConfig.ShowWhatsNewAfterUpdate"/>) and the
|
||||||
|
/// running version changed since the last launch we recorded, open the About box once so
|
||||||
|
/// they see what changed in the update just installed. Always records the current version
|
||||||
|
/// so the change is detected exactly once. A fresh install (no version recorded yet) does
|
||||||
|
/// NOT count as an update. 2026-05-31.</summary>
|
||||||
|
private void MaybeShowWhatsNewAfterUpdate()
|
||||||
|
{
|
||||||
|
if (IsDisposed) return;
|
||||||
|
var current = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "";
|
||||||
|
|
||||||
|
AppConfig cfg;
|
||||||
|
try { cfg = AppConfig.Load(); }
|
||||||
|
catch { return; }
|
||||||
|
|
||||||
|
var versionChanged = !string.IsNullOrEmpty(cfg.LastWhatsNewVersion)
|
||||||
|
&& cfg.LastWhatsNewVersion != current;
|
||||||
|
|
||||||
|
if (cfg.ShowWhatsNewAfterUpdate && versionChanged)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logFile.Event($"what's new: opening About after update {cfg.LastWhatsNewVersion} -> {current}");
|
||||||
|
using var dlg = new AboutDialog();
|
||||||
|
dlg.ShowDialog(this);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logFile.Event($"what's new: failed to open About: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the current version so the next change is detected once. Reload first so we
|
||||||
|
// don't clobber a concurrent config write (e.g. the startup update-check timestamp).
|
||||||
|
if (cfg.LastWhatsNewVersion != current)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fresh = AppConfig.Load();
|
||||||
|
fresh.LastWhatsNewVersion = current;
|
||||||
|
fresh.Save();
|
||||||
|
}
|
||||||
|
catch { /* harmless — at worst we re-show next launch */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===================== UI layout =====================
|
// ===================== UI layout =====================
|
||||||
|
|
||||||
private void BuildLayout()
|
private void BuildLayout()
|
||||||
@@ -1307,6 +1379,15 @@ public sealed class MainForm : Form
|
|||||||
OnLockProfileToggled(lockProfileMenuItem.Checked);
|
OnLockProfileToggled(lockProfileMenuItem.Checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Change this profile's encryption password. Alt+F, P — 'p' is free in the File menu
|
||||||
|
// (O / R / S / A / M / L / N / X are taken). Opens a small dialog showing the current
|
||||||
|
// password (in plain text, so a screen reader can read it) with OK / Cancel.
|
||||||
|
var changePasswordItem = new ToolStripMenuItem("Change this profile's &password...")
|
||||||
|
{
|
||||||
|
AccessibleName = "Change this profile's password",
|
||||||
|
};
|
||||||
|
changePasswordItem.Click += (_, _) => ChangeProfilePassword();
|
||||||
|
|
||||||
var minimiseItem = new ToolStripMenuItem("Mi&nimise to tray")
|
var minimiseItem = new ToolStripMenuItem("Mi&nimise to tray")
|
||||||
{
|
{
|
||||||
// No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → N now —
|
// No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → N now —
|
||||||
@@ -1333,6 +1414,7 @@ public sealed class MainForm : Form
|
|||||||
saveAsItem,
|
saveAsItem,
|
||||||
renameItem,
|
renameItem,
|
||||||
lockProfileMenuItem,
|
lockProfileMenuItem,
|
||||||
|
changePasswordItem,
|
||||||
new ToolStripSeparator(),
|
new ToolStripSeparator(),
|
||||||
minimiseItem,
|
minimiseItem,
|
||||||
new ToolStripSeparator(),
|
new ToolStripSeparator(),
|
||||||
@@ -1383,11 +1465,20 @@ public sealed class MainForm : Form
|
|||||||
};
|
};
|
||||||
prefsItem.Click += (_, _) => OpenPreferencesDialog();
|
prefsItem.Click += (_, _) => OpenPreferencesDialog();
|
||||||
|
|
||||||
|
// Password manager — list every profile with its password, edit any of them in one place.
|
||||||
|
// 'w' mnemonic (pass&words) is free in the Options menu (s / K / t / P are taken).
|
||||||
|
var profilePasswordsItem = new ToolStripMenuItem("Profile pass&words...")
|
||||||
|
{
|
||||||
|
AccessibleName = "Profile passwords",
|
||||||
|
};
|
||||||
|
profilePasswordsItem.Click += (_, _) => OpenProfilePasswordManager();
|
||||||
|
|
||||||
optionsMenu.DropDownItems.AddRange(new ToolStripItem[]
|
optionsMenu.DropDownItems.AddRange(new ToolStripItem[]
|
||||||
{
|
{
|
||||||
recordingSettingsItem,
|
recordingSettingsItem,
|
||||||
keyboardItem,
|
keyboardItem,
|
||||||
startupBehaviourItem,
|
startupBehaviourItem,
|
||||||
|
profilePasswordsItem,
|
||||||
new ToolStripSeparator(),
|
new ToolStripSeparator(),
|
||||||
prefsItem,
|
prefsItem,
|
||||||
});
|
});
|
||||||
@@ -2834,7 +2925,15 @@ public sealed class MainForm : Form
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var label = selectedPeerLabels.GetValueOrDefault(id, ep.Address.ToString());
|
var label = selectedPeerLabels.GetValueOrDefault(id, ep.Address.ToString());
|
||||||
var ghost = new PeerAnnouncement(id, $"{label} (offline)", ep.Port, true, true, DateTime.UtcNow, ep.Address);
|
// Don't call a peer "offline" just because DISCOVERY briefly lost sight of it —
|
||||||
|
// if audio is still arriving from it, or its heartbeat is healthy, it's plainly
|
||||||
|
// still connected. Discovery beacons are easy to miss for a second; the audio
|
||||||
|
// stream and heartbeat are the real signal. Only tag "(offline)" when it's gone
|
||||||
|
// by every measure. 2026-06-02 (Ed's "offline but still sending audio" report).
|
||||||
|
var stillThere = receiver.IsAudioFlowingFrom(ep.Address, TimeSpan.FromSeconds(3))
|
||||||
|
|| IsEndpointHeartbeatHealthy(ep);
|
||||||
|
var suffix = stillThere ? "" : " (offline)";
|
||||||
|
var ghost = new PeerAnnouncement(id, $"{label}{suffix}", ep.Port, true, true, DateTime.UtcNow, ep.Address);
|
||||||
desired.Add((new PeerListItem(ghost), id));
|
desired.Add((new PeerListItem(ghost), id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3158,6 +3257,10 @@ public sealed class MainForm : Form
|
|||||||
// the audio NAT pinhole on the audio port — no separate socket, no +2 port.
|
// the audio NAT pinhole on the audio port — no separate socket, no +2 port.
|
||||||
heartbeatService?.SetTrackedPeers(endpoints);
|
heartbeatService?.SetTrackedPeers(endpoints);
|
||||||
|
|
||||||
|
// Push the current profile-password key + fingerprint down to the sender and receiver so
|
||||||
|
// audio is encrypted/decrypted with it. Cheap when the password hasn't changed.
|
||||||
|
RecomputeAudioCrypto();
|
||||||
|
|
||||||
// Sender does NOT depend on a peer being currently online. As long as the user has ticked
|
// Sender does NOT depend on a peer being currently online. As long as the user has ticked
|
||||||
// "Send my audio" AND a capture device, we keep capturing and emitting UDP. If no peer is
|
// "Send my audio" AND a capture device, we keep capturing and emitting UDP. If no peer is
|
||||||
// selected, packets just go nowhere; the moment a peer is ticked, packets start flowing.
|
// selected, packets just go nowhere; the moment a peer is ticked, packets start flowing.
|
||||||
@@ -3898,15 +4001,27 @@ public sealed class MainForm : Form
|
|||||||
foreach (var peer in byEndpoint.Values) knownPeers[peer.InstanceId] = peer;
|
foreach (var peer in byEndpoint.Values) knownPeers[peer.InstanceId] = peer;
|
||||||
|
|
||||||
// If a selected peer's announced address changed (DHCP renewal, network switch),
|
// If a selected peer's announced address changed (DHCP renewal, network switch),
|
||||||
// update the cached endpoint so the sender follows the new IP.
|
// update the cached endpoint so the sender follows the new IP — BUT only when the
|
||||||
|
// endpoint we're currently using has actually stopped working.
|
||||||
|
//
|
||||||
|
// Why the guard: a peer reachable at two addresses at once — e.g. a VPN address AND a
|
||||||
|
// LAN address — announces itself from both, and discovery reports whichever it heard
|
||||||
|
// last. Blindly following that made the tracked endpoint ping-pong between the two
|
||||||
|
// every couple of seconds. Because this one endpoint feeds the audio sender, the
|
||||||
|
// heartbeat, AND the receiver's allow-list, the ping-pong meant a chunk of audio was
|
||||||
|
// aimed at — or accepted only from — an address that doesn't actually reach the peer,
|
||||||
|
// heard as heavy crackle (Tech Singer's Win7-over-VPN report, 2026-05-31). Keeping the
|
||||||
|
// endpoint pinned while it's still passing heartbeats stops the thrash. A genuine move
|
||||||
|
// (DHCP renewal, Wi-Fi switch) makes the old endpoint go unreachable first, at which
|
||||||
|
// point the guard lets the move through; TryAdoptLiveHeartbeatAddress backs it up.
|
||||||
foreach (var (id, oldEndpoint) in selectedPeerEndpoints.ToList())
|
foreach (var (id, oldEndpoint) in selectedPeerEndpoints.ToList())
|
||||||
{
|
{
|
||||||
if (!knownPeers.TryGetValue(id, out var peer)) continue;
|
if (!knownPeers.TryGetValue(id, out var peer)) continue;
|
||||||
var newEndpoint = new IPEndPoint(peer.Address, peer.AudioPort);
|
var newEndpoint = new IPEndPoint(peer.Address, peer.AudioPort);
|
||||||
if (!newEndpoint.Equals(oldEndpoint))
|
if (!newEndpoint.Equals(oldEndpoint) && !IsEndpointHeartbeatHealthy(oldEndpoint))
|
||||||
{
|
{
|
||||||
selectedPeerEndpoints[id] = newEndpoint;
|
selectedPeerEndpoints[id] = newEndpoint;
|
||||||
logFile.Event($"peer {peer.Name} endpoint moved {oldEndpoint} -> {newEndpoint}");
|
logFile.Event($"peer {peer.Name} endpoint moved {oldEndpoint} -> {newEndpoint} (old endpoint not healthy)");
|
||||||
}
|
}
|
||||||
selectedPeerLabels[id] = peer.Name;
|
selectedPeerLabels[id] = peer.Name;
|
||||||
}
|
}
|
||||||
@@ -3957,6 +4072,23 @@ public sealed class MainForm : Form
|
|||||||
receiver.SetAllowedSenders(SelectedSendEndpoints());
|
receiver.SetAllowedSenders(SelectedSendEndpoints());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the heartbeat currently considers <paramref name="endpoint"/> healthy —
|
||||||
|
/// i.e. we're getting pongs back from exactly that address+port right now. Used to keep the
|
||||||
|
/// audio target pinned to a proven-good endpoint instead of chasing a multi-homed peer's
|
||||||
|
/// other (possibly unreachable) advertised address every discovery refresh. 2026-05-31.</summary>
|
||||||
|
private bool IsEndpointHeartbeatHealthy(IPEndPoint endpoint)
|
||||||
|
{
|
||||||
|
if (heartbeatService is null) return false;
|
||||||
|
foreach (var h in heartbeatService.GetAllPeerHealth())
|
||||||
|
{
|
||||||
|
if (h.State == PeerHealthState.Healthy && h.AudioEndpoint.Equals(endpoint))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stale-address recovery. When exactly one tracked peer has gone Unreachable (its
|
/// Stale-address recovery. When exactly one tracked peer has gone Unreachable (its
|
||||||
/// resolved address — often a stale DNS answer — has no host behind it) and exactly one
|
/// resolved address — often a stale DNS answer — has no host behind it) and exactly one
|
||||||
@@ -4335,6 +4467,9 @@ public sealed class MainForm : Form
|
|||||||
// send / receive routing (WASAPI / ASIO / both). 1 Hz cadence is fine — the user is
|
// send / receive routing (WASAPI / ASIO / both). 1 Hz cadence is fine — the user is
|
||||||
// hovering, not staring at a counter — and BuildTrayTooltip is allocation-cheap.
|
// hovering, not staring at a counter — and BuildTrayTooltip is allocation-cheap.
|
||||||
trayController.SetTooltip(BuildTrayTooltip());
|
trayController.SetTooltip(BuildTrayTooltip());
|
||||||
|
// Surface any password mismatch / out-of-date peer the receiver has spotted (once per
|
||||||
|
// change). Cheap when everything matches.
|
||||||
|
CheckPeerSecurity();
|
||||||
// Periodic native-memory reaper. SustainedLowLatency GC mode (set in Program.Main)
|
// Periodic native-memory reaper. SustainedLowLatency GC mode (set in Program.Main)
|
||||||
// explicitly avoids gen2 collections to keep audio scheduling smooth — but that same
|
// explicitly avoids gen2 collections to keep audio scheduling smooth — but that same
|
||||||
// suppression means finalizers for IDisposable wrappers that didn't get explicit
|
// suppression means finalizers for IDisposable wrappers that didn't get explicit
|
||||||
@@ -5013,6 +5148,20 @@ public sealed class MainForm : Form
|
|||||||
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
|
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
|
||||||
catch { /* baseline failure shouldn't block save */ }
|
catch { /* baseline failure shouldn't block save */ }
|
||||||
unsavedChanges = false;
|
unsavedChanges = false;
|
||||||
|
// A freshly created profile has no password yet, and encryption is always on — so
|
||||||
|
// ask for one now and write it straight into the file we just saved. Skipping (empty
|
||||||
|
// or Cancel) leaves it passwordless; the streaming gate will ask again when needed.
|
||||||
|
if (string.IsNullOrEmpty(currentProfilePassword))
|
||||||
|
{
|
||||||
|
var pw = ProfilePasswordDialog.Show(this, title, "");
|
||||||
|
if (!string.IsNullOrEmpty(pw))
|
||||||
|
{
|
||||||
|
currentProfilePassword = pw;
|
||||||
|
RecomputeAudioCrypto();
|
||||||
|
PersistPasswordOnly(pw);
|
||||||
|
AppendLogEntry($"profile password set on creation for \"{title}\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
// No confirmation popup here. The Save-As dialog the user just dismissed is itself
|
// No confirmation popup here. The Save-As dialog the user just dismissed is itself
|
||||||
// the explicit, user-driven "I am saving to this path" — a follow-up "Saved." popup
|
// the explicit, user-driven "I am saving to this path" — a follow-up "Saved." popup
|
||||||
// is pure friction (one more Enter press, one more NVDA read of the same fact).
|
// is pure friction (one more Enter press, one more NVDA read of the same fact).
|
||||||
@@ -5040,6 +5189,9 @@ public sealed class MainForm : Form
|
|||||||
// unsaved-changes prompt would start firing again, and (worse) that prompt could block
|
// unsaved-changes prompt would start firing again, and (worse) that prompt could block
|
||||||
// an unattended auto-update from restarting. The lock flag must survive every save.
|
// an unattended auto-update from restarting. The lock flag must survive every save.
|
||||||
profile.ReadOnly = currentProfileReadOnly;
|
profile.ReadOnly = currentProfileReadOnly;
|
||||||
|
// Likewise the encryption password (stored scrambled) — carried through every save so a
|
||||||
|
// routine Save never wipes it (same bug class the ReadOnly line above fixes).
|
||||||
|
profile.Password = RemSoundCrypto.Obfuscate(currentProfilePassword);
|
||||||
profile.Volume = volumeBar.Value;
|
profile.Volume = volumeBar.Value;
|
||||||
profile.Muted = receiver.IsMuted;
|
profile.Muted = receiver.IsMuted;
|
||||||
profile.ReceiveAudioOn = receiveAudioCheckbox.Checked;
|
profile.ReceiveAudioOn = receiveAudioCheckbox.Checked;
|
||||||
@@ -5201,6 +5353,179 @@ public sealed class MainForm : Form
|
|||||||
private static string SerializeProfileForDirtyDiff(Profile profile) =>
|
private static string SerializeProfileForDirtyDiff(Profile profile) =>
|
||||||
JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
|
JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
|
||||||
|
/// <summary>File → Change this profile's password. Shows the current password (plain text,
|
||||||
|
/// for the screen reader) in a dialog; on OK, updates the in-memory value and writes JUST
|
||||||
|
/// the password back to the profile file straight away — same immediate-persist approach as
|
||||||
|
/// the lock flag, since the password needs to be there next time the profile is loaded.
|
||||||
|
/// Requires a saved profile (a password is meaningless on the blank template, which has no
|
||||||
|
/// file to attach it to). 2026-05-31.</summary>
|
||||||
|
private void ChangeProfilePassword()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(currentProfileTitle) || string.IsNullOrEmpty(currentProfilePath))
|
||||||
|
{
|
||||||
|
MessageBox.Show(this,
|
||||||
|
"There's no saved profile to attach a password to yet. Save the current setup as a profile first (File → Save as), then set its password.",
|
||||||
|
AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var entered = ProfilePasswordDialog.Show(this, currentProfileTitle, currentProfilePassword);
|
||||||
|
if (entered is null) return; // cancelled
|
||||||
|
currentProfilePassword = entered;
|
||||||
|
RecomputeAudioCrypto();
|
||||||
|
PersistPasswordOnly(entered);
|
||||||
|
AppendLogEntry($"profile password changed for \"{currentProfileTitle}\" (now {(entered.Length == 0 ? "cleared" : "set")})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Write JUST the (scrambled) password back to the profile file, leaving every
|
||||||
|
/// other in-session edit untouched — the same carve-out <see cref="PersistReadOnlyFlagOnly"/>
|
||||||
|
/// uses for the lock flag, so changing the password doesn't silently flush unrelated unsaved
|
||||||
|
/// changes. Read the JSON, set one field, write it back. Blank-template (no path) is a no-op.</summary>
|
||||||
|
private void PersistPasswordOnly(string plaintextPassword)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(currentProfilePath)) return;
|
||||||
|
if (!File.Exists(currentProfilePath)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(currentProfilePath);
|
||||||
|
var profile = JsonSerializer.Deserialize<Profile>(json);
|
||||||
|
if (profile is null) return;
|
||||||
|
profile.Password = RemSoundCrypto.Obfuscate(plaintextPassword);
|
||||||
|
var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
File.WriteAllText(currentProfilePath, newJson);
|
||||||
|
// Refresh the dirty-diff baseline against the rewritten file so the password change
|
||||||
|
// we just persisted doesn't read back as an unsaved change on close.
|
||||||
|
try { baselineProfileJson = SerializeProfileForDirtyDiff(profile); }
|
||||||
|
catch { /* baseline refresh is best-effort */ }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppendLogEntry($"failed to persist profile password: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Derive the audio key + fingerprint from the current profile password (cached so
|
||||||
|
/// the slow derivation only runs when the password actually changes) and push them down to
|
||||||
|
/// the sender and receiver. No password → null key → no audio flows (encryption is
|
||||||
|
/// mandatory). Called on password change, when audio is (re)configured, and on the streaming
|
||||||
|
/// gate. 2026-05-31.</summary>
|
||||||
|
private void RecomputeAudioCrypto()
|
||||||
|
{
|
||||||
|
if (currentProfilePassword != lastDerivedPassword)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(currentProfilePassword))
|
||||||
|
{
|
||||||
|
currentAudioKey = null;
|
||||||
|
currentAudioFingerprint = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
currentAudioKey = RemSoundCrypto.DeriveKey(currentProfilePassword);
|
||||||
|
currentAudioFingerprint = RemSoundCrypto.Fingerprint(currentProfilePassword);
|
||||||
|
}
|
||||||
|
lastDerivedPassword = currentProfilePassword;
|
||||||
|
}
|
||||||
|
sender.AudioKey = currentAudioKey;
|
||||||
|
sender.AudioFingerprint = currentAudioFingerprint;
|
||||||
|
receiver.AudioKey = currentAudioKey;
|
||||||
|
receiver.AudioFingerprint = currentAudioFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The "you need a password before any audio can flow" gate. Called when the user
|
||||||
|
/// ticks Send my audio or Receive audio. If the active profile has no password, prompt for
|
||||||
|
/// one; if they give one, set it (and offer to save it to the profile); if they cancel,
|
||||||
|
/// un-tick the box. Returns true if streaming may proceed.</summary>
|
||||||
|
private bool EnsureStreamingPassword(AccessibleCheckBox box)
|
||||||
|
{
|
||||||
|
if (!box.Checked) return true; // turning OFF never needs a password
|
||||||
|
if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one
|
||||||
|
|
||||||
|
var label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle;
|
||||||
|
var entered = ProfilePasswordDialog.Show(this, label, "");
|
||||||
|
if (string.IsNullOrEmpty(entered))
|
||||||
|
{
|
||||||
|
// No password → can't stream. Put the box back without re-firing this gate.
|
||||||
|
suppressStreamingPasswordGate = true;
|
||||||
|
box.Checked = false;
|
||||||
|
suppressStreamingPasswordGate = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
currentProfilePassword = entered;
|
||||||
|
RecomputeAudioCrypto();
|
||||||
|
// Offer to remember it on the profile (if we're on a saved one).
|
||||||
|
if (!string.IsNullOrEmpty(currentProfileTitle) && !string.IsNullOrEmpty(currentProfilePath))
|
||||||
|
{
|
||||||
|
var save = MessageBox.Show(this,
|
||||||
|
$"Save this password to profile \"{currentProfileTitle}\" so you don't have to type it next time?",
|
||||||
|
AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question);
|
||||||
|
if (save == DialogResult.Yes) PersistPasswordOnly(currentProfilePassword);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Send/receive checkbox handler with the password gate in front of it. Replaces the
|
||||||
|
/// bare HandleCapabilityChange + MarkProfileDirty wiring.</summary>
|
||||||
|
private void OnStreamingCheckboxChanged(AccessibleCheckBox box)
|
||||||
|
{
|
||||||
|
if (suppressStreamingPasswordGate) return;
|
||||||
|
if (!EnsureStreamingPassword(box)) return;
|
||||||
|
HandleCapabilityChange();
|
||||||
|
MarkProfileDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Once a second, surface any password mismatch / out-of-date peer the receiver has
|
||||||
|
/// detected from peers' advertised fingerprints — once per change, not every tick — so a
|
||||||
|
/// silent encrypted stream is never an unexplained mystery.</summary>
|
||||||
|
private void CheckPeerSecurity()
|
||||||
|
{
|
||||||
|
foreach (var kv in receiver.GetPeerSecurityStatuses())
|
||||||
|
{
|
||||||
|
var addr = kv.Key;
|
||||||
|
var status = kv.Value;
|
||||||
|
if (!IsSelectedPeerAddress(addr)) continue;
|
||||||
|
if (status is PeerSecurityStatus.Secure or PeerSecurityStatus.Unknown)
|
||||||
|
{
|
||||||
|
lastSecurityWarned.Remove(addr);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lastSecurityWarned.TryGetValue(addr, out var warned) && warned == status) continue;
|
||||||
|
lastSecurityWarned[addr] = status;
|
||||||
|
AppendLogEntry($"security: {status} with {addr}");
|
||||||
|
var msg = status == PeerSecurityStatus.PasswordMismatch
|
||||||
|
? $"You and {addr} have different passwords, so no audio will pass between you.\n\nMake sure you've both set the same password (File → Change this profile's password)."
|
||||||
|
: $"{addr} is running an older version of RemSound that can't connect securely. They need to update before audio can flow between you.";
|
||||||
|
MessageBox.Show(this, msg, AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsSelectedPeerAddress(System.Net.IPAddress addr) =>
|
||||||
|
selectedPeerEndpoints.Values.Any(e => e.Address.Equals(addr));
|
||||||
|
|
||||||
|
/// <summary>Options → Profile passwords. Opens the password-manager list; if the user changed
|
||||||
|
/// any password, re-sync the active profile's password from disk (the manager wrote it there)
|
||||||
|
/// and re-derive the audio key so the live session uses the new password immediately.</summary>
|
||||||
|
private void OpenProfilePasswordManager()
|
||||||
|
{
|
||||||
|
if (profileStore is null)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "Profile system not active in this run.", AppName,
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var changed = ProfilePasswordManagerDialog.Show(this, profileStore);
|
||||||
|
if (!changed) return;
|
||||||
|
if (!string.IsNullOrEmpty(currentProfilePath) && File.Exists(currentProfilePath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var profile = JsonSerializer.Deserialize<Profile>(File.ReadAllText(currentProfilePath));
|
||||||
|
currentProfilePassword = RemSoundCrypto.Deobfuscate(profile?.Password);
|
||||||
|
RecomputeAudioCrypto();
|
||||||
|
AppendLogEntry("active profile password refreshed from the password manager");
|
||||||
|
}
|
||||||
|
catch { /* benign — worst case the change applies on next load */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Serializes the current control state as if the user had just clicked Save.
|
/// <summary>Serializes the current control state as if the user had just clicked Save.
|
||||||
/// Used for the unsaved-changes-on-close diff. Mirrors <see cref="SaveCurrentStateToProfileFile"/>
|
/// Used for the unsaved-changes-on-close diff. Mirrors <see cref="SaveCurrentStateToProfileFile"/>
|
||||||
/// but doesn't write anywhere.</summary>
|
/// but doesn't write anywhere.</summary>
|
||||||
@@ -5451,7 +5776,7 @@ public sealed class MainForm : Form
|
|||||||
/// Custom paths are per-profile (changed from machine-wide in v3.0.3 development) so
|
/// Custom paths are per-profile (changed from machine-wide in v3.0.3 development) so
|
||||||
/// each profile can carry its own cue palette. The settings cache mirrors the active
|
/// each profile can carry its own cue palette. The settings cache mirrors the active
|
||||||
/// profile's CustomCuePaths dictionary and is the runtime source of truth.</summary>
|
/// profile's CustomCuePaths dictionary and is the runtime source of truth.</summary>
|
||||||
private void TryLoadCueSound(string cueId, string defaultFileName, out System.Media.SoundPlayer? player)
|
private void TryLoadCueSound(string cueId, string defaultFileName, out CuePlayer? player)
|
||||||
{
|
{
|
||||||
player = null;
|
player = null;
|
||||||
try
|
try
|
||||||
@@ -5476,9 +5801,9 @@ public sealed class MainForm : Form
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var sp = new System.Media.SoundPlayer(path);
|
// CuePlayer reads + plays the file on demand (NAudio), so no pre-load step — and it
|
||||||
sp.LoadAsync();
|
// copes with any format, including the 96 kHz / 24-bit cue WAVs and custom user files.
|
||||||
player = sp;
|
player = new CuePlayer(path);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -5521,38 +5846,67 @@ public sealed class MainForm : Form
|
|||||||
var current = heartbeatService.GetAllPeerHealth();
|
var current = heartbeatService.GetAllPeerHealth();
|
||||||
|
|
||||||
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
// How recently audio must have arrived to count the peer as "audibly connected". Audio
|
||||||
|
// arrives hundreds of times a second, so a 3-second gap is a genuine interruption, not
|
||||||
|
// jitter. Disconnect also requires the heartbeat to be Unreachable (5 s of no reply), so
|
||||||
|
// the heartbeat is the slower gate for a real, total loss.
|
||||||
|
var audioWindow = TimeSpan.FromSeconds(3);
|
||||||
foreach (var ph in current)
|
foreach (var ph in current)
|
||||||
{
|
{
|
||||||
var key = $"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}";
|
var key = $"{ph.AudioEndpoint.Address}:{ph.AudioEndpoint.Port}";
|
||||||
seenKeys.Add(key);
|
seenKeys.Add(key);
|
||||||
previousPeerHealthStates.TryGetValue(key, out var prior);
|
|
||||||
if (ph.State == PeerHealthState.Healthy && prior != PeerHealthState.Healthy)
|
var audioFlowing = receiver.IsAudioFlowingFrom(ph.AudioEndpoint.Address, audioWindow);
|
||||||
|
// Connected the moment audio arrives OR the heartbeat is solidly healthy; lost only
|
||||||
|
// when audio has stopped AND the heartbeat has gone unreachable. The middle ground
|
||||||
|
// (heartbeat Stale, or audio briefly paused) holds the previous state — hysteresis,
|
||||||
|
// so a heartbeat blip while audio keeps playing never fires a false disconnect.
|
||||||
|
var isConnected = audioFlowing || ph.State == PeerHealthState.Healthy;
|
||||||
|
var isLost = !audioFlowing && ph.State == PeerHealthState.Unreachable;
|
||||||
|
var wasConnected = peerConnectedState.TryGetValue(key, out var w) && w;
|
||||||
|
|
||||||
|
if (isConnected && !wasConnected)
|
||||||
{
|
{
|
||||||
if (settings.LoadEnableConnectCue()) connectSound?.Play();
|
var enabled = settings.LoadEnableConnectCue();
|
||||||
logFile.Event($"peer connected cue: {ph.AudioEndpoint} ({prior} → Healthy)");
|
if (enabled) connectSound?.Play();
|
||||||
|
logFile.Event($"peer connected: {ph.AudioEndpoint} (audio={audioFlowing}, heartbeat={ph.State}) — connect cue {CueOutcome(enabled, connectSound)}");
|
||||||
|
peerConnectedState[key] = true;
|
||||||
}
|
}
|
||||||
else if (ph.State == PeerHealthState.Unreachable
|
else if (isLost && wasConnected)
|
||||||
&& (prior == PeerHealthState.Healthy || prior == PeerHealthState.Stale))
|
|
||||||
{
|
{
|
||||||
if (settings.LoadEnableDisconnectCue()) disconnectSound?.Play();
|
var enabled = settings.LoadEnableDisconnectCue();
|
||||||
logFile.Event($"peer disconnected cue: {ph.AudioEndpoint} ({prior} → Unreachable)");
|
if (enabled) disconnectSound?.Play();
|
||||||
|
logFile.Event($"peer disconnected: {ph.AudioEndpoint} (audio stopped, heartbeat={ph.State}) — disconnect cue {CueOutcome(enabled, disconnectSound)}");
|
||||||
|
peerConnectedState[key] = false;
|
||||||
|
}
|
||||||
|
else if (!peerConnectedState.ContainsKey(key))
|
||||||
|
{
|
||||||
|
// First sighting and neither clearly connected nor lost (e.g. address typed but
|
||||||
|
// no audio/pong yet) — seed the state without playing a cue.
|
||||||
|
peerConnectedState[key] = isConnected;
|
||||||
}
|
}
|
||||||
previousPeerHealthStates[key] = ph.State;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peers that vanished from tracking entirely (user deselected). Play disconnect if they
|
// Peers that vanished from tracking entirely (user deselected). Play disconnect only if
|
||||||
// were healthy when last seen.
|
// they were connected when last seen — a peer that never connected stays quiet.
|
||||||
foreach (var key in previousPeerHealthStates.Keys.Where(k => !seenKeys.Contains(k)).ToList())
|
foreach (var key in peerConnectedState.Keys.Where(k => !seenKeys.Contains(k)).ToList())
|
||||||
{
|
{
|
||||||
if (previousPeerHealthStates[key] == PeerHealthState.Healthy)
|
if (peerConnectedState[key])
|
||||||
{
|
{
|
||||||
if (settings.LoadEnableDisconnectCue()) disconnectSound?.Play();
|
var enabled = settings.LoadEnableDisconnectCue();
|
||||||
logFile.Event($"peer disconnected cue: {key} (deselected while Healthy)");
|
if (enabled) disconnectSound?.Play();
|
||||||
|
logFile.Event($"peer disconnected: {key} (deselected while connected) — disconnect cue {CueOutcome(enabled, disconnectSound)}");
|
||||||
}
|
}
|
||||||
previousPeerHealthStates.Remove(key);
|
peerConnectedState.Remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Describes what actually happened to a cue, for honest logging: "played", "muted
|
||||||
|
/// in settings", or "enabled but sound not loaded" — so the log never claims a cue rang when
|
||||||
|
/// no sound came out. 2026-06-02.</summary>
|
||||||
|
private static string CueOutcome(bool enabled, CuePlayer? sound) =>
|
||||||
|
!enabled ? "muted in settings" : sound is null ? "enabled but sound not loaded" : "played";
|
||||||
|
|
||||||
private void NudgeVolume(int deltaPercent)
|
private void NudgeVolume(int deltaPercent)
|
||||||
{
|
{
|
||||||
BeginInvoke(() =>
|
BeginInvoke(() =>
|
||||||
|
|||||||
@@ -155,6 +155,15 @@ internal sealed class PreferencesDialog : Form
|
|||||||
AutoSize = true,
|
AutoSize = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// After an update installs and RemSound restarts, opening the About box once lets the user
|
||||||
|
// see what changed. Off by default (opt-in). 'h' mnemonic — 'w' is taken by "Write logs now".
|
||||||
|
private readonly AccessibleCheckBox showWhatsNewAfterUpdateBox = new()
|
||||||
|
{
|
||||||
|
Text = "S&how what's new after each update",
|
||||||
|
AccessibleName = "Show what's new after each update",
|
||||||
|
AutoSize = true,
|
||||||
|
};
|
||||||
|
|
||||||
// UPnP — automatic router port-forwarding via Mono.Nat. Off by default. The status label
|
// UPnP — automatic router port-forwarding via Mono.Nat. Off by default. The status label
|
||||||
// is updated live from the RouterPortMapper.StatusChanged event so the user sees the
|
// is updated live from the RouterPortMapper.StatusChanged event so the user sees the
|
||||||
// discovery result inline without having to close and reopen the dialog.
|
// discovery result inline without having to close and reopen the dialog.
|
||||||
@@ -369,6 +378,13 @@ internal sealed class PreferencesDialog : Form
|
|||||||
cfg.SilentlyInstallUpdates = silentlyInstallUpdatesBox.Checked;
|
cfg.SilentlyInstallUpdates = silentlyInstallUpdatesBox.Checked;
|
||||||
try { cfg.Save(); } catch { /* harmless */ }
|
try { cfg.Save(); } catch { /* harmless */ }
|
||||||
};
|
};
|
||||||
|
showWhatsNewAfterUpdateBox.Checked = cfgForLoad.ShowWhatsNewAfterUpdate;
|
||||||
|
showWhatsNewAfterUpdateBox.CheckedChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
var cfg = AppConfig.Load();
|
||||||
|
cfg.ShowWhatsNewAfterUpdate = showWhatsNewAfterUpdateBox.Checked;
|
||||||
|
try { cfg.Save(); } catch { /* harmless */ }
|
||||||
|
};
|
||||||
checkForUpdatesNowButton.Click += (_, _) => checkForUpdatesNow();
|
checkForUpdatesNowButton.Click += (_, _) => checkForUpdatesNow();
|
||||||
|
|
||||||
// UPnP toggle — persists immediately and tells MainForm to start / stop the mapper.
|
// UPnP toggle — persists immediately and tells MainForm to start / stop the mapper.
|
||||||
@@ -445,10 +461,11 @@ internal sealed class PreferencesDialog : Form
|
|||||||
updateFrequencyBox.TabIndex = 6;
|
updateFrequencyBox.TabIndex = 6;
|
||||||
checkForUpdatesNowButton.TabIndex = 7;
|
checkForUpdatesNowButton.TabIndex = 7;
|
||||||
silentlyInstallUpdatesBox.TabIndex = 8;
|
silentlyInstallUpdatesBox.TabIndex = 8;
|
||||||
upnpEnabledBox.TabIndex = 9;
|
showWhatsNewAfterUpdateBox.TabIndex = 9;
|
||||||
loggingBox.TabIndex = 10;
|
upnpEnabledBox.TabIndex = 10;
|
||||||
writeLogsNowButton.TabIndex = 11;
|
loggingBox.TabIndex = 11;
|
||||||
closeButton.TabIndex = 12;
|
writeLogsNowButton.TabIndex = 12;
|
||||||
|
closeButton.TabIndex = 13;
|
||||||
|
|
||||||
// Group the frequency label + combo on one FlowLayoutPanel row so the visible label
|
// 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.
|
// sits inline next to the combo while keeping the combo as the focusable target.
|
||||||
@@ -500,10 +517,11 @@ internal sealed class PreferencesDialog : Form
|
|||||||
panel.Controls.Add(freqRow, 0, 4);
|
panel.Controls.Add(freqRow, 0, 4);
|
||||||
panel.Controls.Add(checkForUpdatesNowButton, 0, 5);
|
panel.Controls.Add(checkForUpdatesNowButton, 0, 5);
|
||||||
panel.Controls.Add(silentlyInstallUpdatesBox, 0, 6);
|
panel.Controls.Add(silentlyInstallUpdatesBox, 0, 6);
|
||||||
panel.Controls.Add(upnpEnabledBox, 0, 7);
|
panel.Controls.Add(showWhatsNewAfterUpdateBox, 0, 7);
|
||||||
panel.Controls.Add(upnpStatusLabel, 0, 8);
|
panel.Controls.Add(upnpEnabledBox, 0, 8);
|
||||||
panel.Controls.Add(loggingBox, 0, 9);
|
panel.Controls.Add(upnpStatusLabel, 0, 9);
|
||||||
panel.Controls.Add(writeLogsNowButton, 0, 10);
|
panel.Controls.Add(loggingBox, 0, 10);
|
||||||
|
panel.Controls.Add(writeLogsNowButton, 0, 11);
|
||||||
|
|
||||||
var buttons = new FlowLayoutPanel
|
var buttons = new FlowLayoutPanel
|
||||||
{
|
{
|
||||||
@@ -624,8 +642,9 @@ internal sealed class PreferencesDialog : Form
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sp = new System.Media.SoundPlayer(path);
|
// CuePlayer (NAudio) rather than System.Media.SoundPlayer so the preview copes with
|
||||||
sp.Play();
|
// any format — including 24-bit / 96 kHz files the basic player can't handle.
|
||||||
|
new CuePlayer(path).Play();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
namespace RemSound.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Small dialog for viewing and changing the active profile's encryption password. Shows the
|
||||||
|
/// current password in a PLAIN (readable) edit box, not a masked one, on purpose: this audience
|
||||||
|
/// uses a screen reader, and a masked password field reads as a run of bullets, which is
|
||||||
|
/// useless. Letting NVDA read the actual characters is far more usable, and the security model
|
||||||
|
/// already accepts that the password is recoverable from the profile file anyway.
|
||||||
|
///
|
||||||
|
/// Returns the new password on OK (which may be empty — that clears the password), or null on
|
||||||
|
/// Cancel. The result is trimmed so an invisible trailing space can't cause a maddening
|
||||||
|
/// "we typed the same password but it won't connect" mismatch between two peers. 2026-05-31.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ProfilePasswordDialog
|
||||||
|
{
|
||||||
|
public static string? Show(IWin32Window owner, string profileTitle, string currentPassword)
|
||||||
|
{
|
||||||
|
using var dialog = new Form
|
||||||
|
{
|
||||||
|
Text = "Change profile password",
|
||||||
|
StartPosition = FormStartPosition.CenterParent,
|
||||||
|
FormBorderStyle = FormBorderStyle.FixedDialog,
|
||||||
|
MinimizeBox = false,
|
||||||
|
MaximizeBox = false,
|
||||||
|
ShowInTaskbar = false,
|
||||||
|
ClientSize = new Size(460, 150),
|
||||||
|
AccessibleName = "Change profile password",
|
||||||
|
};
|
||||||
|
|
||||||
|
var label = new Label
|
||||||
|
{
|
||||||
|
Text = $"Password for profile “{profileTitle}”:",
|
||||||
|
AutoSize = true,
|
||||||
|
};
|
||||||
|
var textBox = new TextBox
|
||||||
|
{
|
||||||
|
Text = currentPassword,
|
||||||
|
Dock = DockStyle.Top,
|
||||||
|
Width = 400,
|
||||||
|
AccessibleName = $"Password for profile {profileTitle}",
|
||||||
|
};
|
||||||
|
var hint = new Label
|
||||||
|
{
|
||||||
|
Text = "Both you and the person you're connecting to must use the same password.",
|
||||||
|
AutoSize = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK };
|
||||||
|
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
|
||||||
|
textBox.KeyDown += (_, args) =>
|
||||||
|
{
|
||||||
|
if (args.KeyCode == Keys.Enter)
|
||||||
|
{
|
||||||
|
dialog.DialogResult = DialogResult.OK;
|
||||||
|
dialog.Close();
|
||||||
|
args.Handled = true;
|
||||||
|
args.SuppressKeyPress = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), RowCount = 4, ColumnCount = 1 };
|
||||||
|
panel.Controls.Add(label, 0, 0);
|
||||||
|
panel.Controls.Add(textBox, 0, 1);
|
||||||
|
panel.Controls.Add(hint, 0, 2);
|
||||||
|
var buttons = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill };
|
||||||
|
buttons.Controls.Add(okButton);
|
||||||
|
buttons.Controls.Add(cancelButton);
|
||||||
|
panel.Controls.Add(buttons, 0, 3);
|
||||||
|
dialog.Controls.Add(panel);
|
||||||
|
dialog.AcceptButton = okButton;
|
||||||
|
dialog.CancelButton = cancelButton;
|
||||||
|
|
||||||
|
return dialog.ShowDialog(owner) == DialogResult.OK ? textBox.Text.Trim() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using RemSound.Core;
|
||||||
|
|
||||||
|
namespace RemSound.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The "password manager" view from Options: every saved profile listed with its password in a
|
||||||
|
/// plain, editable box. Type a new password against any profile and OK writes them all back to
|
||||||
|
/// disk. Plain (readable) boxes on purpose — a masked field reads as bullets to a screen reader,
|
||||||
|
/// which is useless; the security model already accepts the password is recoverable from the
|
||||||
|
/// profile file. Only the password field of each changed profile is rewritten; nothing else in
|
||||||
|
/// the profile is touched. Returns true if any password was changed. 2026-05-31.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ProfilePasswordManagerDialog
|
||||||
|
{
|
||||||
|
public static bool Show(IWin32Window owner, ProfileStore store)
|
||||||
|
{
|
||||||
|
var titles = store.ListProfileTitles();
|
||||||
|
|
||||||
|
using var dialog = new Form
|
||||||
|
{
|
||||||
|
Text = "Profile passwords",
|
||||||
|
StartPosition = FormStartPosition.CenterParent,
|
||||||
|
FormBorderStyle = FormBorderStyle.Sizable,
|
||||||
|
MinimizeBox = false,
|
||||||
|
MaximizeBox = true,
|
||||||
|
ShowInTaskbar = false,
|
||||||
|
ClientSize = new Size(520, 420),
|
||||||
|
MinimumSize = new Size(420, 240),
|
||||||
|
AccessibleName = "Profile passwords",
|
||||||
|
};
|
||||||
|
|
||||||
|
var intro = new Label
|
||||||
|
{
|
||||||
|
Text = titles.Count == 0
|
||||||
|
? "You don't have any saved profiles yet. Create one with File → Save as, and it will ask you for a password."
|
||||||
|
: "Each profile has its own password. You and the person you connect to must use the same password. Edit any box and press OK to save.",
|
||||||
|
Dock = DockStyle.Top,
|
||||||
|
AutoSize = false,
|
||||||
|
Height = 48,
|
||||||
|
Padding = new Padding(12, 10, 12, 4),
|
||||||
|
};
|
||||||
|
|
||||||
|
var grid = new TableLayoutPanel
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Fill,
|
||||||
|
AutoScroll = true,
|
||||||
|
ColumnCount = 2,
|
||||||
|
RowCount = titles.Count,
|
||||||
|
Padding = new Padding(12, 0, 12, 8),
|
||||||
|
};
|
||||||
|
grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||||
|
grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||||
|
|
||||||
|
var rows = new List<(string Title, string Original, TextBox Box)>();
|
||||||
|
foreach (var title in titles)
|
||||||
|
{
|
||||||
|
string current;
|
||||||
|
try { current = RemSoundCrypto.Deobfuscate(store.Load(title)?.Password); }
|
||||||
|
catch { current = ""; }
|
||||||
|
|
||||||
|
var label = new Label { Text = title, AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(0, 6, 10, 6) };
|
||||||
|
var box = new TextBox { Text = current, Anchor = AnchorStyles.Left | AnchorStyles.Right, AccessibleName = $"Password for profile {title}" };
|
||||||
|
grid.Controls.Add(label);
|
||||||
|
grid.Controls.Add(box);
|
||||||
|
rows.Add((title, current, box));
|
||||||
|
}
|
||||||
|
|
||||||
|
var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK };
|
||||||
|
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
|
||||||
|
var buttons = new FlowLayoutPanel { Dock = DockStyle.Bottom, FlowDirection = FlowDirection.RightToLeft, AutoSize = true, Padding = new Padding(8) };
|
||||||
|
buttons.Controls.Add(okButton);
|
||||||
|
buttons.Controls.Add(cancelButton);
|
||||||
|
|
||||||
|
dialog.Controls.Add(grid);
|
||||||
|
dialog.Controls.Add(buttons);
|
||||||
|
dialog.Controls.Add(intro);
|
||||||
|
dialog.AcceptButton = okButton;
|
||||||
|
dialog.CancelButton = cancelButton;
|
||||||
|
|
||||||
|
if (dialog.ShowDialog(owner) != DialogResult.OK) return false;
|
||||||
|
|
||||||
|
var changedAny = false;
|
||||||
|
foreach (var (title, original, box) in rows)
|
||||||
|
{
|
||||||
|
var now = box.Text.Trim();
|
||||||
|
if (now == original) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = store.PathFor(title);
|
||||||
|
if (!File.Exists(path)) continue;
|
||||||
|
var profile = JsonSerializer.Deserialize<Profile>(File.ReadAllText(path));
|
||||||
|
if (profile is null) continue;
|
||||||
|
profile.Password = RemSoundCrypto.Obfuscate(now);
|
||||||
|
File.WriteAllText(path, JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
changedAny = true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Skip a profile we couldn't rewrite; the others still save.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changedAny;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
tag_name on the latest GitHub release; bump it on every public release. The
|
tag_name on the latest GitHub release; bump it on every public release. The
|
||||||
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
|
||||||
is what the About dialog and the updater both read. -->
|
is what the About dialog and the updater both read. -->
|
||||||
<Version>3.2.0</Version>
|
<Version>3.3.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -104,6 +104,20 @@ public sealed class AppConfig
|
|||||||
/// startup check; the periodic timer (if set) still runs.</summary>
|
/// startup check; the periodic timer (if set) still runs.</summary>
|
||||||
public bool CheckForUpdatesOnStartup { get; set; } = true;
|
public bool CheckForUpdatesOnStartup { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>If true, RemSound opens the About box (which leads with the latest release
|
||||||
|
/// notes) once on the first launch AFTER an update has been installed, so the user sees
|
||||||
|
/// "what's new" without going looking. Default false — opt-in. Detected by comparing the
|
||||||
|
/// running version against <see cref="LastWhatsNewVersion"/> at launch, so it only fires
|
||||||
|
/// when the version actually changed, never on an ordinary relaunch. On by default — it's a
|
||||||
|
/// discoverability aid (see what changed), not a data-persistence toggle, so the usual
|
||||||
|
/// "auto-options default off" rule doesn't really apply; users can untick it.</summary>
|
||||||
|
public bool ShowWhatsNewAfterUpdate { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>The app version recorded at the last launch. Used only to detect "the version
|
||||||
|
/// changed since last run" for <see cref="ShowWhatsNewAfterUpdate"/>. Null until first
|
||||||
|
/// recorded, so a fresh install never counts as an update.</summary>
|
||||||
|
public string? LastWhatsNewVersion { get; set; }
|
||||||
|
|
||||||
/// <summary>If true, RemSound tries to open the audio port (UDP 47830) on the local router
|
/// <summary>If true, RemSound tries to open the audio port (UDP 47830) on the local router
|
||||||
/// using UPnP / NAT-PMP / PCP, so peers on the public internet can reach this machine
|
/// using UPnP / NAT-PMP / PCP, so peers on the public internet can reach this machine
|
||||||
/// without manual port forwarding. Default false — the toggle opt-in only, because some
|
/// without manual port forwarding. Default false — the toggle opt-in only, because some
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ public sealed class Profile
|
|||||||
/// any other in-session edits stay session-only. 2026-05-22.</summary>
|
/// any other in-session edits stay session-only. 2026-05-22.</summary>
|
||||||
public bool ReadOnly { get; set; }
|
public bool ReadOnly { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The profile's encryption password, stored LIGHTLY SCRAMBLED on disk (via
|
||||||
|
/// <see cref="RemSoundCrypto.Obfuscate"/> — not real encryption, just so it isn't legible at
|
||||||
|
/// a glance in a possibly-synced JSON file). Null/empty = no password set yet. Two peers can
|
||||||
|
/// exchange audio only when their profile passwords match, because the audio key is derived
|
||||||
|
/// from this. In memory the running value is the plain text; this field holds the scrambled
|
||||||
|
/// form. Added 2026-05-31 for the always-on encryption feature.</summary>
|
||||||
|
public string? Password { get; set; }
|
||||||
|
|
||||||
// === Main form: send / receive ===
|
// === Main form: send / receive ===
|
||||||
public bool ReceiveAudioOn { get; set; }
|
public bool ReceiveAudioOn { get; set; }
|
||||||
public bool SendAudioOn { get; set; }
|
public bool SendAudioOn { get; set; }
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ public static class RemPacket
|
|||||||
/// before reading the Lane field; payloads shorter than that default Lane to
|
/// before reading the Lane field; payloads shorter than that default Lane to
|
||||||
/// <see cref="RenderRoute.Mixed"/>. Senders newer than 2026-05-11 always write this size.</summary>
|
/// <see cref="RenderRoute.Mixed"/>. Senders newer than 2026-05-11 always write this size.</summary>
|
||||||
public const int FormatPayloadExtendedSize = 36;
|
public const int FormatPayloadExtendedSize = 36;
|
||||||
|
/// <summary>Format payload size with the 8-byte password fingerprint appended (36 + 8). Sent
|
||||||
|
/// by encryption-capable builds (2026-05-31+) so a peer can tell whether its profile password
|
||||||
|
/// matches ours WITHOUT either side sending the password. Receivers read the fingerprint only
|
||||||
|
/// when <c>payload.Length >= FormatPayloadWithFingerprintSize</c>; shorter payloads (older
|
||||||
|
/// senders) yield a null fingerprint, which the receiver reads as "this peer can't encrypt —
|
||||||
|
/// it needs to update". The fingerprint sits AFTER the 36-byte extended block, so it composes
|
||||||
|
/// with the existing Lane extension and stays backward-compatible.</summary>
|
||||||
|
public const int FormatPayloadWithFingerprintSize = 44;
|
||||||
|
/// <summary>Length of the password fingerprint carried in the extended format payload.</summary>
|
||||||
|
public const int PasswordFingerprintSize = 8;
|
||||||
// KeepAlivePayloadSize removed 2026-05-23 — no code reads or writes this payload any more
|
// KeepAlivePayloadSize removed 2026-05-23 — no code reads or writes this payload any more
|
||||||
// (see top-of-file comment). RemPacketType.KeepAlive itself is retained for wire safety.
|
// (see top-of-file comment). RemPacketType.KeepAlive itself is retained for wire safety.
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -143,7 +153,7 @@ public static class RemPacket
|
|||||||
/// ignore the trailing 4 — see the <see cref="FormatPayloadSize"/> doc comment for the
|
/// ignore the trailing 4 — see the <see cref="FormatPayloadSize"/> doc comment for the
|
||||||
/// compatibility contract.
|
/// compatibility contract.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static int WriteFormatPayload(Span<byte> destination, AudioFormatInfo format)
|
public static int WriteFormatPayload(Span<byte> destination, AudioFormatInfo format, ReadOnlySpan<byte> passwordFingerprint = default)
|
||||||
{
|
{
|
||||||
if (destination.Length < FormatPayloadExtendedSize)
|
if (destination.Length < FormatPayloadExtendedSize)
|
||||||
{
|
{
|
||||||
@@ -168,6 +178,14 @@ public static class RemPacket
|
|||||||
destination[33] = 0;
|
destination[33] = 0;
|
||||||
destination[34] = 0;
|
destination[34] = 0;
|
||||||
destination[35] = 0;
|
destination[35] = 0;
|
||||||
|
// Optional 8-byte password fingerprint at offset 36. Written only when the caller
|
||||||
|
// supplies one AND the destination has room — keeps the 36-byte path unchanged for any
|
||||||
|
// caller that doesn't pass a fingerprint.
|
||||||
|
if (passwordFingerprint.Length == PasswordFingerprintSize && destination.Length >= FormatPayloadWithFingerprintSize)
|
||||||
|
{
|
||||||
|
passwordFingerprint.CopyTo(destination.Slice(36, PasswordFingerprintSize));
|
||||||
|
return FormatPayloadWithFingerprintSize;
|
||||||
|
}
|
||||||
return FormatPayloadExtendedSize;
|
return FormatPayloadExtendedSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,10 +215,22 @@ public static class RemPacket
|
|||||||
/// rather than rejected — better to play the audio in the default route than drop a
|
/// rather than rejected — better to play the audio in the default route than drop a
|
||||||
/// stream because a future sender sent an unknown value.
|
/// stream because a future sender sent an unknown value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool TryReadFormat(ReadOnlySpan<byte> payload, out AudioFormatInfo format)
|
public static bool TryReadFormat(ReadOnlySpan<byte> payload, out AudioFormatInfo format) =>
|
||||||
|
TryReadFormat(payload, out format, out _);
|
||||||
|
|
||||||
|
/// <summary>As <see cref="TryReadFormat(ReadOnlySpan{byte}, out AudioFormatInfo)"/>, also
|
||||||
|
/// recovering the sender's 8-byte password fingerprint when present (payload long enough).
|
||||||
|
/// <paramref name="passwordFingerprint"/> is null when the sender didn't include one — i.e.
|
||||||
|
/// a pre-encryption build, which the receiver treats as "this peer needs to update".</summary>
|
||||||
|
public static bool TryReadFormat(ReadOnlySpan<byte> payload, out AudioFormatInfo format, out byte[]? passwordFingerprint)
|
||||||
{
|
{
|
||||||
format = new AudioFormatInfo(48000, 2, 32, 3, 8, 384000);
|
format = new AudioFormatInfo(48000, 2, 32, 3, 8, 384000);
|
||||||
|
passwordFingerprint = null;
|
||||||
if (payload.Length < FormatPayloadSize) return false;
|
if (payload.Length < FormatPayloadSize) return false;
|
||||||
|
if (payload.Length >= FormatPayloadWithFingerprintSize)
|
||||||
|
{
|
||||||
|
passwordFingerprint = payload.Slice(36, PasswordFingerprintSize).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
var lane = RenderRoute.Mixed;
|
var lane = RenderRoute.Mixed;
|
||||||
if (payload.Length >= FormatPayloadExtendedSize)
|
if (payload.Length >= FormatPayloadExtendedSize)
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace RemSound.Core;
|
||||||
|
|
||||||
|
/// <summary>How a selected peer's encryption lines up with ours, derived from the password
|
||||||
|
/// fingerprint they advertise in their format packets.</summary>
|
||||||
|
public enum PeerSecurityStatus
|
||||||
|
{
|
||||||
|
/// <summary>No fingerprint seen yet (or we have no password set) — nothing to report.</summary>
|
||||||
|
Unknown,
|
||||||
|
/// <summary>Their password fingerprint matches ours: audio will decrypt, the link is secure.</summary>
|
||||||
|
Secure,
|
||||||
|
/// <summary>They advertised a fingerprint, but it differs from ours — different passwords, so
|
||||||
|
/// no audio will pass. The user needs to make the two passwords match.</summary>
|
||||||
|
PasswordMismatch,
|
||||||
|
/// <summary>They sent format packets with no fingerprint at all — an older, pre-encryption
|
||||||
|
/// build. They need to update before audio can flow.</summary>
|
||||||
|
PeerNeedsUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cryptographic helpers for RemSound's always-on audio encryption (in development, 2026-05-31).
|
||||||
|
///
|
||||||
|
/// Model (agreed design): each profile carries a password. Two peers can exchange audio only
|
||||||
|
/// when their profile passwords match, because the audio is encrypted with a key derived from
|
||||||
|
/// the password — same password → same key → each side can unscramble the other; different
|
||||||
|
/// passwords → the integrity check fails and packets are dropped (silence, never garbage).
|
||||||
|
///
|
||||||
|
/// Primitives:
|
||||||
|
/// * <see cref="DeriveKey"/> — turns a password into a 256-bit AES key via PBKDF2 (slow on
|
||||||
|
/// purpose, to make guessing expensive). Run once per password and cached by the caller,
|
||||||
|
/// never per packet.
|
||||||
|
/// * <see cref="Fingerprint"/> — a short, non-reversible id two peers can compare to discover
|
||||||
|
/// they share a password WITHOUT sending it. Different salt from the key so it can't double
|
||||||
|
/// as the key.
|
||||||
|
/// * <see cref="Encrypt"/> / <see cref="TryDecrypt"/> — AES-256-GCM (authenticated) on a
|
||||||
|
/// packet payload. Fast (microseconds, hardware-accelerated). A wrong key fails the auth
|
||||||
|
/// tag and TryDecrypt returns false.
|
||||||
|
/// * <see cref="Obfuscate"/> / <see cref="Deobfuscate"/> — a LIGHT, reversible scramble for
|
||||||
|
/// the password as it sits in the profile JSON. NOT encryption (the key is in the binary):
|
||||||
|
/// it just keeps the password from being readable at a glance in a possibly-synced file.
|
||||||
|
/// That's an accepted trade-off of a portable per-profile password.
|
||||||
|
///
|
||||||
|
/// All algorithms are supported back to Windows 7 (PBKDF2 is pure-managed; AES-GCM goes through
|
||||||
|
/// Windows CNG) — worth verifying on a real Win7 box before this ships, same as the updater.
|
||||||
|
/// </summary>
|
||||||
|
public static class RemSoundCrypto
|
||||||
|
{
|
||||||
|
private const int KeyBytes = 32; // AES-256
|
||||||
|
private const int FingerprintBytes = 8; // enough to compare; not a key
|
||||||
|
private const int NonceBytes = 12; // AES-GCM standard nonce
|
||||||
|
private const int TagBytes = 16; // AES-GCM auth tag
|
||||||
|
|
||||||
|
// PBKDF2 cost. High enough to make brute-forcing a captured fingerprint expensive, low
|
||||||
|
// enough not to stall a connect on older (Win7-era) hardware. Run once per password, cached.
|
||||||
|
private const int Pbkdf2Iterations = 100_000;
|
||||||
|
|
||||||
|
// Fixed salts. A per-connection random salt would be stronger, but both peers must derive
|
||||||
|
// the SAME key from the SAME password with no key-exchange round, so the salt has to be
|
||||||
|
// shared and known in advance. Distinct salts keep the key and the fingerprint independent.
|
||||||
|
private static readonly byte[] KeySalt = Encoding.UTF8.GetBytes("RemSound.v1.audio-key");
|
||||||
|
private static readonly byte[] FingerprintSalt = Encoding.UTF8.GetBytes("RemSound.v1.fingerprint");
|
||||||
|
|
||||||
|
// Repeating-XOR key for the light on-disk scramble (see class summary — NOT security).
|
||||||
|
private static readonly byte[] ObfuscationKey =
|
||||||
|
Encoding.UTF8.GetBytes("RemSound-profile-password-scramble-v1");
|
||||||
|
|
||||||
|
/// <summary>Derive the 256-bit AES key for a password. Cache the result; never call per packet.</summary>
|
||||||
|
public static byte[] DeriveKey(string? password) =>
|
||||||
|
Rfc2898DeriveBytes.Pbkdf2(
|
||||||
|
Encoding.UTF8.GetBytes(password ?? ""), KeySalt, Pbkdf2Iterations, HashAlgorithmName.SHA256, KeyBytes);
|
||||||
|
|
||||||
|
/// <summary>A short, non-reversible id for a password. Two peers compare fingerprints to
|
||||||
|
/// learn they share a password without revealing it.</summary>
|
||||||
|
public static byte[] Fingerprint(string? password) =>
|
||||||
|
Rfc2898DeriveBytes.Pbkdf2(
|
||||||
|
Encoding.UTF8.GetBytes(password ?? ""), FingerprintSalt, Pbkdf2Iterations, HashAlgorithmName.SHA256, FingerprintBytes);
|
||||||
|
|
||||||
|
/// <summary>Encrypt a payload. Output layout: nonce(12) || tag(16) || ciphertext. A fresh
|
||||||
|
/// random nonce is generated per call. (The live wire layer may later derive the nonce from
|
||||||
|
/// the packet sequence number instead, which is the textbook approach for a long-lived key.)</summary>
|
||||||
|
public static byte[] Encrypt(byte[] key, ReadOnlySpan<byte> plaintext)
|
||||||
|
{
|
||||||
|
var nonce = new byte[NonceBytes];
|
||||||
|
RandomNumberGenerator.Fill(nonce);
|
||||||
|
var ciphertext = new byte[plaintext.Length];
|
||||||
|
var tag = new byte[TagBytes];
|
||||||
|
using (var aes = new AesGcm(key, TagBytes))
|
||||||
|
{
|
||||||
|
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||||
|
}
|
||||||
|
var output = new byte[NonceBytes + TagBytes + ciphertext.Length];
|
||||||
|
Buffer.BlockCopy(nonce, 0, output, 0, NonceBytes);
|
||||||
|
Buffer.BlockCopy(tag, 0, output, NonceBytes, TagBytes);
|
||||||
|
Buffer.BlockCopy(ciphertext, 0, output, NonceBytes + TagBytes, ciphertext.Length);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reverse <see cref="Encrypt"/>. Returns false (and an empty payload) if the auth
|
||||||
|
/// tag doesn't verify — i.e. the key is wrong or the packet was tampered with.</summary>
|
||||||
|
public static bool TryDecrypt(byte[] key, ReadOnlySpan<byte> packet, out byte[] plaintext)
|
||||||
|
{
|
||||||
|
plaintext = [];
|
||||||
|
if (packet.Length < NonceBytes + TagBytes) return false;
|
||||||
|
var nonce = packet[..NonceBytes];
|
||||||
|
var tag = packet.Slice(NonceBytes, TagBytes);
|
||||||
|
var ciphertext = packet[(NonceBytes + TagBytes)..];
|
||||||
|
var result = new byte[ciphertext.Length];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var aes = new AesGcm(key, TagBytes);
|
||||||
|
aes.Decrypt(nonce, ciphertext, tag, result);
|
||||||
|
plaintext = result;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
return false; // wrong key or tampered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The number of bytes <see cref="EncryptInto"/> adds on top of the plaintext
|
||||||
|
/// length (nonce + tag). Callers size their buffers and MTU budgets against this.</summary>
|
||||||
|
public const int EncryptionOverheadBytes = NonceBytes + TagBytes; // 28
|
||||||
|
|
||||||
|
/// <summary>Build a reusable AES-GCM cipher for a key. The caller owns it (it's IDisposable)
|
||||||
|
/// and reuses it across many packets — far cheaper than constructing one per packet. AES-GCM
|
||||||
|
/// is NOT thread-safe, so give each thread (each sender lane; the single receiver thread)
|
||||||
|
/// its own.</summary>
|
||||||
|
public static AesGcm CreateGcm(byte[] key) => new(key, TagBytes);
|
||||||
|
|
||||||
|
/// <summary>Low-allocation encrypt straight into a destination span. Layout written:
|
||||||
|
/// nonce(12) || tag(16) || ciphertext. Returns the number of bytes written
|
||||||
|
/// (= plaintext.Length + <see cref="EncryptionOverheadBytes"/>). <paramref name="dst"/> must
|
||||||
|
/// be at least that big. Generates a fresh random nonce per call (safe at our packet rates).</summary>
|
||||||
|
public static int EncryptInto(AesGcm gcm, ReadOnlySpan<byte> plaintext, Span<byte> dst)
|
||||||
|
{
|
||||||
|
var total = plaintext.Length + EncryptionOverheadBytes;
|
||||||
|
if (dst.Length < total) throw new ArgumentException("Encrypt destination too small", nameof(dst));
|
||||||
|
var nonce = dst[..NonceBytes];
|
||||||
|
RandomNumberGenerator.Fill(nonce);
|
||||||
|
gcm.Encrypt(nonce, plaintext, dst.Slice(NonceBytes + TagBytes, plaintext.Length), dst.Slice(NonceBytes, TagBytes));
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Low-allocation decrypt of an <see cref="EncryptInto"/> packet into a destination
|
||||||
|
/// span. Returns true and the plaintext length on success; false if the packet is too short,
|
||||||
|
/// the destination too small, or the auth tag fails (wrong key / tampered).</summary>
|
||||||
|
public static bool TryDecryptInto(AesGcm gcm, ReadOnlySpan<byte> packet, Span<byte> dst, out int written)
|
||||||
|
{
|
||||||
|
written = 0;
|
||||||
|
if (packet.Length < EncryptionOverheadBytes) return false;
|
||||||
|
var ctLen = packet.Length - EncryptionOverheadBytes;
|
||||||
|
if (dst.Length < ctLen) return false;
|
||||||
|
var nonce = packet[..NonceBytes];
|
||||||
|
var tag = packet.Slice(NonceBytes, TagBytes);
|
||||||
|
var ciphertext = packet.Slice(NonceBytes + TagBytes, ctLen);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
gcm.Decrypt(nonce, ciphertext, tag, dst[..ctLen]);
|
||||||
|
written = ctLen;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
return false; // wrong key or tampered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Constant-time equality for two fingerprints (or any small byte spans). Avoids
|
||||||
|
/// leaking, via timing, how much of a fingerprint matched.</summary>
|
||||||
|
public static bool FingerprintsEqual(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b) =>
|
||||||
|
CryptographicOperations.FixedTimeEquals(a, b);
|
||||||
|
|
||||||
|
/// <summary>Light, reversible scramble of a password for storage in the profile JSON. NOT
|
||||||
|
/// encryption — just so the password isn't legible at a glance. Empty in, empty out.</summary>
|
||||||
|
public static string Obfuscate(string? plaintext)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(plaintext)) return "";
|
||||||
|
var data = Encoding.UTF8.GetBytes(plaintext);
|
||||||
|
for (var i = 0; i < data.Length; i++) data[i] ^= ObfuscationKey[i % ObfuscationKey.Length];
|
||||||
|
return Convert.ToBase64String(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reverse <see cref="Obfuscate"/>. Returns "" for null/empty/garbage input.</summary>
|
||||||
|
public static string Deobfuscate(string? stored)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(stored)) return "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = Convert.FromBase64String(stored);
|
||||||
|
for (var i = 0; i < data.Length; i++) data[i] ^= ObfuscationKey[i % ObfuscationKey.Length];
|
||||||
|
return Encoding.UTF8.GetString(data);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using RemSound.Core;
|
||||||
|
|
||||||
|
namespace RemSound.Receiver;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrypts incoming audio payloads with the key derived from the local profile's password.
|
||||||
|
/// One instance is shared by every <see cref="StreamSession"/>, which is safe because all
|
||||||
|
/// receive-side decode work runs on the single network-listener thread (see StreamSession's
|
||||||
|
/// class summary). The cipher is rebuilt only when the key reference changes (a password
|
||||||
|
/// change), and a single reusable scratch buffer avoids per-packet allocation on the hot path.
|
||||||
|
/// 2026-05-31.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AudioDecryptor : IDisposable
|
||||||
|
{
|
||||||
|
private AesGcm? gcm;
|
||||||
|
private byte[]? keyCached;
|
||||||
|
// Sized for the largest decrypted frame (Opus 20 ms / PCM 5 ms are well under this).
|
||||||
|
private readonly byte[] scratch = new byte[8192];
|
||||||
|
|
||||||
|
/// <summary>True once a password/key is set — without one, nothing can be decrypted and all
|
||||||
|
/// audio is dropped (encryption is mandatory).</summary>
|
||||||
|
public bool HasKey => gcm is not null;
|
||||||
|
|
||||||
|
/// <summary>Rebuild the cipher if the key reference changed. Call on the network thread
|
||||||
|
/// before decrypting. Pushing a new array (not mutating in place) is what signals a change.</summary>
|
||||||
|
public void EnsureKey(byte[]? key)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(key, keyCached)) return;
|
||||||
|
gcm?.Dispose();
|
||||||
|
gcm = key is null ? null : RemSoundCrypto.CreateGcm(key);
|
||||||
|
keyCached = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decrypt a ciphertext payload into the shared scratch. Returns the plaintext span
|
||||||
|
/// (a view into the scratch, valid until the next call) or an empty span on failure — wrong
|
||||||
|
/// key (password mismatch), tampered packet, or no key set. Single-threaded use only.</summary>
|
||||||
|
public ReadOnlySpan<byte> TryDecrypt(ReadOnlySpan<byte> ciphertext)
|
||||||
|
{
|
||||||
|
if (gcm is null) return default;
|
||||||
|
return RemSoundCrypto.TryDecryptInto(gcm, ciphertext, scratch, out var len)
|
||||||
|
? scratch.AsSpan(0, len)
|
||||||
|
: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
gcm?.Dispose();
|
||||||
|
gcm = null;
|
||||||
|
keyCached = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,54 @@ public sealed class AudioReceiver : IDisposable
|
|||||||
// OnHeartbeatReceived, regardless of the user's "Receive audio" tick state.
|
// OnHeartbeatReceived, regardless of the user's "Receive audio" tick state.
|
||||||
private volatile bool playbackEnabled;
|
private volatile bool playbackEnabled;
|
||||||
|
|
||||||
|
// Audio decryption (2026-05-31). One shared decryptor — all receive decode runs on the
|
||||||
|
// single network thread, so no per-session cipher is needed. AudioKey is the AES key derived
|
||||||
|
// from the local profile's password; AudioFingerprint is the short id of that password we
|
||||||
|
// compare against the fingerprints peers advertise in their format packets. Both are pushed
|
||||||
|
// down by the app and read on the network thread (hence volatile). peerSecurity records the
|
||||||
|
// latest match result per peer address for the app to surface ("passwords don't match" etc.).
|
||||||
|
private readonly AudioDecryptor decryptor = new();
|
||||||
|
private volatile byte[]? audioKey;
|
||||||
|
private volatile byte[]? audioFingerprint;
|
||||||
|
public byte[]? AudioKey { get => audioKey; set => audioKey = value; }
|
||||||
|
public byte[]? AudioFingerprint { get => audioFingerprint; set => audioFingerprint = value; }
|
||||||
|
private readonly object securityLock = new();
|
||||||
|
private readonly Dictionary<IPAddress, PeerSecurityStatus> peerSecurity = new();
|
||||||
|
|
||||||
|
/// <summary>Latest per-peer encryption status (whether their profile password matches ours),
|
||||||
|
/// derived from the fingerprint each peer advertises in its format packets. Keyed by source
|
||||||
|
/// address. The app polls this to tell the user about a password mismatch or an out-of-date
|
||||||
|
/// peer instead of leaving a silent stream a mystery.</summary>
|
||||||
|
public IReadOnlyList<KeyValuePair<IPAddress, PeerSecurityStatus>> GetPeerSecurityStatuses()
|
||||||
|
{
|
||||||
|
lock (securityLock)
|
||||||
|
{
|
||||||
|
return peerSecurity.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if decoded audio from <paramref name="address"/> has been written to a
|
||||||
|
/// playout buffer within <paramref name="within"/>. The app uses this to drive the
|
||||||
|
/// connect/disconnect cues off the ACTUAL audio stream rather than the heartbeat alone —
|
||||||
|
/// so a heartbeat blip while audio keeps flowing never fires a false "disconnect" cue, and
|
||||||
|
/// the connect cue can fire the moment audio starts. Returns false when not receiving (no
|
||||||
|
/// sessions), so the caller falls back to the heartbeat for send-only setups. 2026-05-31.</summary>
|
||||||
|
public bool IsAudioFlowingFrom(IPAddress address, TimeSpan within)
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.UtcNow - within;
|
||||||
|
lock (sessionsLock)
|
||||||
|
{
|
||||||
|
foreach (var session in sessions.Values)
|
||||||
|
{
|
||||||
|
if (session.Endpoint.Address.Equals(address) && session.LastWriteUtc >= cutoff)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Allowed-senders gate. The App ticks peer checkboxes; only those endpoints' audio reaches
|
// Allowed-senders gate. The App ticks peer checkboxes; only those endpoints' audio reaches
|
||||||
// the playout. A null set means "no filter" (legacy behaviour). An empty set means "block
|
// the playout. A null set means "no filter" (legacy behaviour). An empty set means "block
|
||||||
// everyone". Stored as IP addresses (not full IPEndPoint) because incoming packets carry
|
// everyone". Stored as IP addresses (not full IPEndPoint) because incoming packets carry
|
||||||
@@ -215,6 +263,30 @@ public sealed class AudioReceiver : IDisposable
|
|||||||
/// the allow-list. Surfaced via diagnostics so we can confirm the filter is working.</summary>
|
/// the allow-list. Surfaced via diagnostics so we can confirm the filter is working.</summary>
|
||||||
public long PacketsRejectedNotAllowed => Interlocked.Read(ref packetsRejectedNotAllowed);
|
public long PacketsRejectedNotAllowed => Interlocked.Read(ref packetsRejectedNotAllowed);
|
||||||
|
|
||||||
|
private void UpdatePeerSecurity(IPAddress address, byte[]? peerFingerprint)
|
||||||
|
{
|
||||||
|
var myFp = audioFingerprint;
|
||||||
|
PeerSecurityStatus status;
|
||||||
|
if (peerFingerprint is null)
|
||||||
|
{
|
||||||
|
status = PeerSecurityStatus.PeerNeedsUpdate; // peer is on a pre-encryption build
|
||||||
|
}
|
||||||
|
else if (myFp is null)
|
||||||
|
{
|
||||||
|
status = PeerSecurityStatus.Unknown; // we have no password set ourselves yet
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
status = RemSoundCrypto.FingerprintsEqual(peerFingerprint, myFp)
|
||||||
|
? PeerSecurityStatus.Secure
|
||||||
|
: PeerSecurityStatus.PasswordMismatch;
|
||||||
|
}
|
||||||
|
lock (securityLock)
|
||||||
|
{
|
||||||
|
peerSecurity[address] = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsSenderAllowed(IPEndPoint remote)
|
private bool IsSenderAllowed(IPEndPoint remote)
|
||||||
{
|
{
|
||||||
var snapshot = allowedSenders;
|
var snapshot = allowedSenders;
|
||||||
@@ -649,6 +721,7 @@ public sealed class AudioReceiver : IDisposable
|
|||||||
Stop();
|
Stop();
|
||||||
listener.Dispose();
|
listener.Dispose();
|
||||||
multiOutput.Dispose();
|
multiOutput.Dispose();
|
||||||
|
decryptor.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -848,7 +921,7 @@ public sealed class AudioReceiver : IDisposable
|
|||||||
// honest — disabled-playback drops aren't a malformedness signal.
|
// honest — disabled-playback drops aren't a malformedness signal.
|
||||||
if (!playbackEnabled) return;
|
if (!playbackEnabled) return;
|
||||||
|
|
||||||
if (!RemPacket.TryReadFormat(payload, out var format))
|
if (!RemPacket.TryReadFormat(payload, out var format, out var peerFingerprint))
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref packetsDropped);
|
Interlocked.Increment(ref packetsDropped);
|
||||||
return;
|
return;
|
||||||
@@ -864,6 +937,11 @@ public sealed class AudioReceiver : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record whether this (selected) peer's profile password matches ours, from the
|
||||||
|
// fingerprint they advertised in this format packet. The app reads this to tell the user
|
||||||
|
// about a password mismatch (or an out-of-date peer) instead of leaving silence a mystery.
|
||||||
|
UpdatePeerSecurity(remote.Address, peerFingerprint);
|
||||||
|
|
||||||
SessionPlayout sp;
|
SessionPlayout sp;
|
||||||
StreamSession? newSession = null;
|
StreamSession? newSession = null;
|
||||||
bool isNewSession = false;
|
bool isNewSession = false;
|
||||||
@@ -907,7 +985,7 @@ public sealed class AudioReceiver : IDisposable
|
|||||||
isFormatChange = true;
|
isFormatChange = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
newSession = new StreamSession(remote, streamId, format, sp, diagnostics, _ => sp.NoteFramesQueued(playoutEngine.TargetLatencyMs));
|
newSession = new StreamSession(remote, streamId, format, sp, diagnostics, _ => sp.NoteFramesQueued(playoutEngine.TargetLatencyMs), decryptor);
|
||||||
sessions[key] = newSession;
|
sessions[key] = newSession;
|
||||||
|
|
||||||
// Same-lane streamId rotation: drop other sessions from this peer that share the
|
// Same-lane streamId rotation: drop other sessions from this peer that share the
|
||||||
@@ -994,6 +1072,9 @@ public sealed class AudioReceiver : IDisposable
|
|||||||
Interlocked.Increment(ref packetsRejectedNotAllowed);
|
Interlocked.Increment(ref packetsRejectedNotAllowed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Make sure the decryptor reflects the current profile password before the session
|
||||||
|
// tries to decrypt (cheap reference check; rebuild only happens on a password change).
|
||||||
|
decryptor.EnsureKey(audioKey);
|
||||||
StreamSession? session;
|
StreamSession? session;
|
||||||
lock (sessionsLock)
|
lock (sessionsLock)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ internal sealed class StreamSession : IDisposable
|
|||||||
private readonly SessionPlayout sessionPlayout;
|
private readonly SessionPlayout sessionPlayout;
|
||||||
private readonly ReceiverDiagnostics diagnostics;
|
private readonly ReceiverDiagnostics diagnostics;
|
||||||
private readonly Action<int> onFramesQueued;
|
private readonly Action<int> onFramesQueued;
|
||||||
|
private readonly AudioDecryptor decryptor;
|
||||||
private readonly PcmFrameAssembler pcmAssembler = new();
|
private readonly PcmFrameAssembler pcmAssembler = new();
|
||||||
private IOpusDecoder? opusDecoder;
|
private IOpusDecoder? opusDecoder;
|
||||||
// Sequence-tracking for Opus FEC recovery. uint, so wrap-around is naturally
|
// Sequence-tracking for Opus FEC recovery. uint, so wrap-around is naturally
|
||||||
@@ -87,7 +88,8 @@ internal sealed class StreamSession : IDisposable
|
|||||||
AudioFormatInfo format,
|
AudioFormatInfo format,
|
||||||
SessionPlayout sessionPlayout,
|
SessionPlayout sessionPlayout,
|
||||||
ReceiverDiagnostics diagnostics,
|
ReceiverDiagnostics diagnostics,
|
||||||
Action<int> onFramesQueued)
|
Action<int> onFramesQueued,
|
||||||
|
AudioDecryptor decryptor)
|
||||||
{
|
{
|
||||||
Endpoint = endpoint;
|
Endpoint = endpoint;
|
||||||
StreamId = streamId;
|
StreamId = streamId;
|
||||||
@@ -95,6 +97,7 @@ internal sealed class StreamSession : IDisposable
|
|||||||
this.sessionPlayout = sessionPlayout;
|
this.sessionPlayout = sessionPlayout;
|
||||||
this.diagnostics = diagnostics;
|
this.diagnostics = diagnostics;
|
||||||
this.onFramesQueued = onFramesQueued;
|
this.onFramesQueued = onFramesQueued;
|
||||||
|
this.decryptor = decryptor;
|
||||||
|
|
||||||
if (Codec == AudioTransportCodec.Opus)
|
if (Codec == AudioTransportCodec.Opus)
|
||||||
{
|
{
|
||||||
@@ -211,12 +214,18 @@ internal sealed class StreamSession : IDisposable
|
|||||||
return true; // pending or dropped due to mismatch — not an error condition
|
return true; // pending or dropped due to mismatch — not an error condition
|
||||||
}
|
}
|
||||||
|
|
||||||
// assembled is signed int24 LE, stereo. Convert to float32 and queue.
|
// The reassembled frame is ciphertext — decrypt it. An empty result means the peer's
|
||||||
var sampleCount = assembled.Length / 3;
|
// password doesn't match ours (or we have no key): drop silently. The app surfaces the
|
||||||
|
// mismatch from the fingerprint in the format packet, so it isn't a mystery to the user.
|
||||||
|
var assembledPlain = decryptor.TryDecrypt(assembled);
|
||||||
|
if (assembledPlain.IsEmpty) return false;
|
||||||
|
|
||||||
|
// assembledPlain is signed int24 LE, stereo. Convert to float32 and queue.
|
||||||
|
var sampleCount = assembledPlain.Length / 3;
|
||||||
var floatBytes = sampleCount * sizeof(float);
|
var floatBytes = sampleCount * sizeof(float);
|
||||||
Span<byte> floatScratch = floatBytes <= 16 * 1024 ? stackalloc byte[floatBytes] : new byte[floatBytes];
|
Span<byte> floatScratch = floatBytes <= 16 * 1024 ? stackalloc byte[floatBytes] : new byte[floatBytes];
|
||||||
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
|
var floatSpan = MemoryMarshal.Cast<byte, float>(floatScratch);
|
||||||
PcmPack.Int24LEToFloat(assembled, floatSpan);
|
PcmPack.Int24LEToFloat(assembledPlain, floatSpan);
|
||||||
|
|
||||||
// Discontinuity probe — what does the audio look like right after we decode it?
|
// Discontinuity probe — what does the audio look like right after we decode it?
|
||||||
// Compared to the sender's pre-encode probe, a higher value here would mean the
|
// Compared to the sender's pre-encode probe, a higher value here would mean the
|
||||||
@@ -235,6 +244,12 @@ internal sealed class StreamSession : IDisposable
|
|||||||
{
|
{
|
||||||
if (opusDecoder is null) return false;
|
if (opusDecoder is null) return false;
|
||||||
|
|
||||||
|
// Decrypt the Opus payload up front; both the FEC pass and the normal decode below use
|
||||||
|
// the plaintext. An empty result = wrong password / no key set → drop (silence). The
|
||||||
|
// mismatch is surfaced to the user from the format-packet fingerprint. 2026-05-31.
|
||||||
|
payload = decryptor.TryDecrypt(payload);
|
||||||
|
if (payload.IsEmpty) return false;
|
||||||
|
|
||||||
// Frame size in samples-per-channel comes directly off the wire in v3.0+ (was
|
// Frame size in samples-per-channel comes directly off the wire in v3.0+ (was
|
||||||
// SampleRate × ms / 1000 in v2.x). Floor at 120 = 2.5 ms = standard libopus
|
// SampleRate × ms / 1000 in v2.x). Floor at 120 = 2.5 ms = standard libopus
|
||||||
// RESTRICTED_LOWDELAY minimum, so a malformed format packet with a tiny value can't
|
// RESTRICTED_LOWDELAY minimum, so a malformed format packet with a tiny value can't
|
||||||
|
|||||||
@@ -92,6 +92,21 @@ public sealed class AudioSender : IDisposable
|
|||||||
// mode (= 120 samples) can be expressed cleanly. Only meaningful when codec == Opus.
|
// mode (= 120 samples) can be expressed cleanly. Only meaningful when codec == Opus.
|
||||||
private volatile int opusFrameSamples = 480;
|
private volatile int opusFrameSamples = 480;
|
||||||
private volatile bool muted;
|
private volatile bool muted;
|
||||||
|
|
||||||
|
// Audio encryption (2026-05-31). The key is derived from the active profile's password by
|
||||||
|
// the app and pushed down here; the lanes read it on their capture threads (hence volatile)
|
||||||
|
// and rebuild their ciphers when the reference changes. The fingerprint is a short, non-
|
||||||
|
// reversible id of the same password, sent in the format packet so a peer can detect a
|
||||||
|
// password mismatch. Null until a password is set — with no key the lanes send nothing.
|
||||||
|
private volatile byte[]? audioKey;
|
||||||
|
private volatile byte[]? audioFingerprint;
|
||||||
|
/// <summary>The AES key derived from the active profile's password (or null = no password).
|
||||||
|
/// Set by the app; read by the sender lanes. Pushing a new array (not mutating in place)
|
||||||
|
/// is what signals the lanes to rebuild their ciphers.</summary>
|
||||||
|
public byte[]? AudioKey { get => audioKey; set => audioKey = value; }
|
||||||
|
/// <summary>Short non-reversible fingerprint of the active password, advertised in the format
|
||||||
|
/// packet for peer password-match detection. Null = none.</summary>
|
||||||
|
public byte[]? AudioFingerprint { get => audioFingerprint; set => audioFingerprint = value; }
|
||||||
private IPEndPoint[] receivers = [];
|
private IPEndPoint[] receivers = [];
|
||||||
private long packetsSent;
|
private long packetsSent;
|
||||||
private long bytesSent;
|
private long bytesSent;
|
||||||
@@ -630,6 +645,8 @@ public sealed class AudioSender : IDisposable
|
|||||||
Stop();
|
Stop();
|
||||||
try { inboundCts?.Cancel(); } catch { /* ignore */ }
|
try { inboundCts?.Cancel(); } catch { /* ignore */ }
|
||||||
try { inboundThread?.Join(500); } catch { /* ignore */ }
|
try { inboundThread?.Join(500); } catch { /* ignore */ }
|
||||||
|
try { defaultLane.DisposeCrypto(); } catch { /* ignore */ }
|
||||||
|
try { asioLane.DisposeCrypto(); } catch { /* ignore */ }
|
||||||
engine.Dispose();
|
engine.Dispose();
|
||||||
// Dispose the persistent ASIO LAST, after the engine that was borrowing it. The
|
// Dispose the persistent ASIO LAST, after the engine that was borrowing it. The
|
||||||
// composite's Dispose doesn't touch the persistent instance (it borrowed it); we
|
// composite's Dispose doesn't touch the persistent instance (it borrowed it); we
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
using RemSound.Core;
|
using RemSound.Core;
|
||||||
|
|
||||||
namespace RemSound.Sender;
|
namespace RemSound.Sender;
|
||||||
@@ -38,6 +39,16 @@ internal sealed class SenderLane
|
|||||||
private int frameAccumulatorWritten;
|
private int frameAccumulatorWritten;
|
||||||
private readonly byte[] outboundScratch = new byte[2048];
|
private readonly byte[] outboundScratch = new byte[2048];
|
||||||
|
|
||||||
|
// Audio encryption (always on as of the 2026-05-31 encryption feature). Each lane keeps its
|
||||||
|
// OWN AES-GCM cipher because AES-GCM isn't thread-safe and the two lanes run on separate
|
||||||
|
// capture threads. Rebuilt only when the key reference changes (rare — a password change);
|
||||||
|
// null when no password is set, in which case the lane sends nothing (mandatory encryption).
|
||||||
|
// cipherScratch holds the per-frame ciphertext (plaintext + 28 bytes overhead); 4096 covers
|
||||||
|
// the largest single frame (Opus 20 ms or PCM 5 ms) with room to spare.
|
||||||
|
private AesGcm? cryptoGcm;
|
||||||
|
private byte[]? cryptoKeyCached;
|
||||||
|
private readonly byte[] cipherScratch = new byte[4096];
|
||||||
|
|
||||||
// Per-stream sequence counters. audioSequence is what the receiver's gap-detector and Opus
|
// Per-stream sequence counters. audioSequence is what the receiver's gap-detector and Opus
|
||||||
// FEC look at — it must stay monotonic per stream. formatSequence is used for the periodic
|
// FEC look at — it must stay monotonic per stream. formatSequence is used for the periodic
|
||||||
// format-announce packet; receiver doesn't sequence-check format packets but having a
|
// format-announce packet; receiver doesn't sequence-check format packets but having a
|
||||||
@@ -262,8 +273,21 @@ internal sealed class SenderLane
|
|||||||
{
|
{
|
||||||
PcmPack.FloatToInt24LE(stereoFloats, int24);
|
PcmPack.FloatToInt24LE(stereoFloats, int24);
|
||||||
}
|
}
|
||||||
|
EnsureCrypto();
|
||||||
|
if (cryptoGcm is null) return; // no password yet → never send audio in the clear
|
||||||
|
// Encrypt the whole PCM frame, then split the ciphertext across as many parts as the
|
||||||
|
// Ethernet payload budget needs (the +28-byte crypto overhead can push a 5 ms frame over
|
||||||
|
// a single datagram). The receiver reassembles the parts and then decrypts.
|
||||||
|
var ctLen = RemSoundCrypto.EncryptInto(cryptoGcm, int24, cipherScratch);
|
||||||
|
var maxPart = RemPacket.MaxAudioPayloadBytes;
|
||||||
|
var totalParts = (byte)((ctLen + maxPart - 1) / maxPart);
|
||||||
pcmFrameId++;
|
pcmFrameId++;
|
||||||
SendPcmPart(pcmFrameId, partIndex: 0, totalParts: 1, int24);
|
for (byte part = 0; part < totalParts; part++)
|
||||||
|
{
|
||||||
|
var offset = part * maxPart;
|
||||||
|
var len = Math.Min(maxPart, ctLen - offset);
|
||||||
|
SendPcmPart(pcmFrameId, part, totalParts, cipherScratch.AsSpan(offset, len));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EmitOpusFrame(ReadOnlySpan<float> stereoFloats)
|
private void EmitOpusFrame(ReadOnlySpan<float> stereoFloats)
|
||||||
@@ -282,7 +306,10 @@ internal sealed class SenderLane
|
|||||||
if (len <= 0) return;
|
if (len <= 0) return;
|
||||||
opusBytes = opusEncoder.LastEncoded(len);
|
opusBytes = opusEncoder.LastEncoded(len);
|
||||||
}
|
}
|
||||||
SendAudio(opusBytes);
|
EnsureCrypto();
|
||||||
|
if (cryptoGcm is null) return; // no password yet → never send audio in the clear
|
||||||
|
var ctLen = RemSoundCrypto.EncryptInto(cryptoGcm, opusBytes, cipherScratch);
|
||||||
|
SendAudio(cipherScratch.AsSpan(0, ctLen));
|
||||||
}
|
}
|
||||||
|
|
||||||
// === wire path ===
|
// === wire path ===
|
||||||
@@ -312,10 +339,34 @@ internal sealed class SenderLane
|
|||||||
// belongs to. The Lane value carried here comes from the AudioFormatInfo constructed
|
// belongs to. The Lane value carried here comes from the AudioFormatInfo constructed
|
||||||
// above, which currently always sets Mixed for the default lane; Stage 4 will set
|
// above, which currently always sets Mixed for the default lane; Stage 4 will set
|
||||||
// WasapiLane / AsioLane on the second lane in BothIndependent mode.
|
// WasapiLane / AsioLane on the second lane in BothIndependent mode.
|
||||||
Span<byte> packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.FormatPayloadExtendedSize];
|
Span<byte> packet = stackalloc byte[RemPacket.HeaderSize + RemPacket.FormatPayloadWithFingerprintSize];
|
||||||
RemPacket.WriteHeader(packet, RemPacketType.Format, streamId, ++formatSequence);
|
RemPacket.WriteHeader(packet, RemPacketType.Format, streamId, ++formatSequence);
|
||||||
RemPacket.WriteFormatPayload(packet[RemPacket.HeaderSize..], format);
|
// Append our password fingerprint so the peer can tell whether its profile password
|
||||||
owner.SendToAll(packet);
|
// matches ours without anyone sending the password. WriteFormatPayload returns 36 (no
|
||||||
|
// fingerprint set) or 44 (fingerprint written); we send exactly that many payload bytes.
|
||||||
|
var payloadLen = RemPacket.WriteFormatPayload(packet[RemPacket.HeaderSize..], format, owner.AudioFingerprint);
|
||||||
|
owner.SendToAll(packet[..(RemPacket.HeaderSize + payloadLen)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rebuild this lane's AES-GCM cipher if the owner's audio key reference changed.
|
||||||
|
/// Cheap reference check on the hot path; the actual rebuild only happens on a password
|
||||||
|
/// change. Null key (no password) leaves the cipher null, which stops the lane sending.</summary>
|
||||||
|
private void EnsureCrypto()
|
||||||
|
{
|
||||||
|
var key = owner.AudioKey;
|
||||||
|
if (ReferenceEquals(key, cryptoKeyCached)) return;
|
||||||
|
cryptoGcm?.Dispose();
|
||||||
|
cryptoGcm = key is null ? null : RemSoundCrypto.CreateGcm(key);
|
||||||
|
cryptoKeyCached = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Release the AES-GCM cipher's native handle. Called from AudioSender.Dispose so
|
||||||
|
/// the handle doesn't leak on teardown (same native-handle discipline as the Opus encoder).</summary>
|
||||||
|
public void DisposeCrypto()
|
||||||
|
{
|
||||||
|
cryptoGcm?.Dispose();
|
||||||
|
cryptoGcm = null;
|
||||||
|
cryptoKeyCached = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SendPcmPart(uint frameId, byte partIndex, byte totalParts, ReadOnlySpan<byte> partBytes)
|
private void SendPcmPart(uint frameId, byte partIndex, byte totalParts, ReadOnlySpan<byte> partBytes)
|
||||||
|
|||||||
Reference in New Issue
Block a user