diff --git a/.gitignore b/.gitignore index e602eaf..490a938 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,10 @@ publish/ CLAUDE.md # Dev notes — running development chronology and per-investigation plans. Useful locally -# for the author; not intended for the public repo. +# for the author; not intended for the public repo. The agent handover holds local paths, +# the Pi address, and the SSH key path, so it must stay out of the public repo. HANDOVER.md +REMSOUND_HANDOVER.md PLAN-*.md # IDE / editor diff --git a/MANUAL.md b/MANUAL.md index 17bc16b..894d521 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -16,7 +16,7 @@ I built RemSound to solve a specific problem of my own. I do a lot of work on a 1. [Connectivity tab](#6-connectivity-tab) 1. [Audio inputs and outputs tab](#7-audio-inputs-and-outputs-tab) 1. [Audio profile tab](#8-audio-profile-tab) - 1. [Pan and EQ tab](#9-pan-and-eq-tab) + 1. [Volume, pan and EQ for peers tab](#9-volume-pan-and-eq-for-peers-tab) 1. [ASIO and WASAPI](#10-asio-and-wasapi) 1. [Peers — finding and connecting](#11-peers--finding-and-connecting) 1. [How the network works (LAN, WAN, Tailscale)](#12-how-the-network-works-lan-wan-tailscale) @@ -188,14 +188,14 @@ So in summary, on a locked profile: The main window has three parts, stacked top to bottom: 1. A **menu bar** at the top with four menus — _File_ , _Record_ , _Options_ and _Help_. See Menus. - 2. A **row of tabs** with three tabs — Connectivity, Audio inputs and outputs, Audio profile. A fourth tab, **Pan and EQ** , appears just before Audio profile if you switch it on in Preferences (it's off by default — see Pan and EQ tab). Each tab has its own Alt+letter shortcuts that only work when that tab is the one showing — so the same letter can do different things on different tabs without clashing. + 2. A **row of tabs** with three tabs — Connectivity, Audio inputs and outputs, Audio profile. A fourth tab, **Volume, pan and EQ for peers** , appears just before Audio profile and is shown by default (you can hide it in Preferences — see Volume, pan and EQ for peers tab). Each tab has its own Alt+letter shortcuts that only work when that tab is the one showing — so the same letter can do different things on different tabs without clashing. 3. A **status line** at the bottom that updates once a second with how long you've been connected, how many peers you have, whether sound is flowing, connection health, and RemSound's own CPU and memory usage. Tab| What it's for ---|--- **Connectivity**| Connected, discovered and remembered peers. Adding a peer by address. A connection status read-out. **Audio inputs and outputs**| The ASIO driver picker (when an ASIO driver is installed), the Receive audio and Send my audio checkboxes, and all the device lists. Choosing a real driver in the picker brings up the ASIO device lists alongside the ordinary Windows ones; choosing _(none)_ hides them. -**Pan and EQ** (optional)| Shape each connected peer's sound on its own — their volume, pan (left/right) and EQ. Hidden by default; tick “Show the Pan and EQ tab” on the General tab of Preferences to show it. See Pan and EQ tab. +**Volume, pan and EQ for peers** (optional)| Shape each connected peer's sound on its own — their volume, pan (left/right) and EQ. Shown by default; untick “Show the volume, pan and EQ for peers tab” on the General tab of Preferences to hide it. See Volume, pan and EQ for peers tab. **Audio profile**| Codec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into an _Audio send parameters_ group and an _Audio receive parameters_ group. ### The system tray icon and its menu @@ -275,7 +275,7 @@ Item| Shortcut| What it does **Profile passwords …**| Alt+O, W| Lists every profile alongside its password, so you can view or change any of them in one place. **Reset the default audio device prompt**| Alt+O, R| Brings back the “use only the default audio device?” question if you previously ticked “Don't ask me this again” on it. See Following the Windows default audio device. **Enable / Disable Realtek ASIO**| —| Only shown if a Realtek ASIO driver is installed. Lets you reverse the choice RemSound offered about disabling that driver (Realtek's generic ASIO driver tends to grab the wrong device and clash with your screen reader). -**Preferences …**| Ctrl+P, or Alt+O, P| Opens the Preferences dialog, organised into five tabs (move between them with Ctrl+Tab, or the arrow keys when the tab names have focus): **General** — the profiles folder, accept remote volume commands, UPnP router opening, and “Show the Pan and EQ tab” (off by default; turns on the optional Pan and EQ tab); **Audio cues** — the cue list and its sounds (see Audio cue sounds); **Startup behaviour** — start minimised / with Windows / with a specific profile; **Update settings** — the update checks and install options; and **Logging** — enable logs, write logs now, and the log-folder housekeeping (see Logs and diagnostics). Esc or the Close button dismisses it. +**Preferences …**| Ctrl+P, or Alt+O, P| Opens the Preferences dialog, organised into five tabs (move between them with Ctrl+Tab, or the arrow keys when the tab names have focus): **General** — the profiles folder, accept remote volume commands, UPnP router opening, and “Show the volume, pan and EQ for peers tab” (on by default; hides the Volume, pan and EQ for peers tab if you untick it); **Audio cues** — the cue list and its sounds (see Audio cue sounds); **Startup behaviour** — start minimised / with Windows / with a specific profile; **Update settings** — the update checks and install options; and **Logging** — enable logs, write logs now, and the log-folder housekeeping (see Logs and diagnostics). Esc or the Close button dismisses it. ### Help menu @@ -310,7 +310,7 @@ Control| Shortcut| What it does **Receive audio**| Alt+R| The master switch for receiving. When it's off, no sound plays out, no matter which output devices are ticked. **WASAPI outputs for received sound**| Alt+3| Tick which ordinary Windows outputs (speakers, headsets) should play the received sound. Ticking more than one means the received sound plays out of all of them at once. **ASIO outputs for received sound**| Alt+1| (Shown when an ASIO driver is chosen.) Tick which ASIO channel pairs should play the received sound. -**Set volume for all received audio**| Alt+V| A slider: the master volume for everything coming in. There is no separate volume per device or per person. +**Master receive volume**| Alt+V| A slider: the master volume for everything coming in. There is no separate volume per device here; per-person volume lives on the Volume, pan and EQ for peers tab. **Send my audio**| Alt+S| The master switch for sending. **WASAPI outputs to send**| Alt+4| Tick which Windows output devices to capture from — this captures whatever is currently playing on those speakers and sends it. **WASAPI inputs to send**| Alt+5| Tick which Windows input devices to capture (microphones, line-ins). @@ -399,30 +399,43 @@ Control| Shortcut| What it does Most people only need to pick a codec and a smoothness level, and leave everything else at its default. -## 9. Pan and EQ tab +## 9. Volume, pan and EQ for peers tab -This tab lets you shape the sound of each peer you're connected to, one at a time. You can set how loud that person is, lean them to the left or right, and change their tone with an equaliser. It's handy when you have several people connected at once and want to mix them — for a jam session you might put the drummer over to the left, turn someone down a little, or brighten someone up. +This tab lets you shape the sound of each peer you're connected to. You can set how loud that person is, lean them to the left or right, and change their tone with an equaliser. It's handy when you have several people connected at once and want to mix them — for a jam session you might put the drummer over to the left, turn someone down a little, or brighten someone up. -The Pan and EQ tab is **optional and hidden by default**. To turn it on, tick **“ Show the Pan and EQ tab”** on the **General** tab of Preferences (Options → Preferences, or Ctrl+P). Once it's on, the tab appears just before the Audio profile tab. +The tab is **shown by default**. If you don't want it, untick **“ Show the volume, pan and EQ for peers tab”** on the **General** tab of Preferences (Options → Preferences, or Ctrl+P). When it's on, the tab appears just before the Audio profile tab. -### Shaping one peer at a time +### Turning it on, and choosing who to shape -You always work on a single peer. First pick the person you want from the peer list, then use the controls below it — they all act on whoever you've selected. The tab, from top to bottom: +There's a single master switch, then a list of the people you're connected to. Tick a person in the list to shape them; whoever your cursor is on in the list is the person the controls below are editing. So you arrow to someone, tab down, and their volume, pan and EQ are right there. Unticking a person leaves their settings intact but passes their sound through untouched — a quick per-person bypass. The tab, from top to bottom: Control| What it does ---|--- -**Enable EQ for peers** (checkbox)| A master switch for the equaliser. When it's off, any EQ you've dialled in has no effect — but you can still set it up ready for when you turn it on. -**Enable pan for peers** (checkbox)| A master switch for panning. When it's off, any panning you've set has no effect, though you can still set it up in advance. Volume is different — it has no switch and is always in effect (but at 100% it changes nothing). -**Peer to shape** (list)| The peers you're currently connected to. Pick the one you want to work on. Everything below acts on the peer you select here. +**Enable volume, pan and EQ for all peers** (checkbox)| The one master switch. When it's off, everyone passes through untouched — but you can still set everything up ready for when you turn it on. There's also a global keyboard shortcut to flip this switch from anywhere (you set the key yourself in Keyboard shortcuts — it starts unset). +**Peers** (checklist)| The people you're currently connected to. Tick the ones you want shaped; untick to bypass a person while keeping their settings. Move your cursor onto a person to edit them — everything below acts on whoever the cursor is on. **Volume** (slider)| An individual level for that one peer, from 0 to 100% (100% means unchanged). It sits on top of your main volume, so you can balance people against each other. **Pan** (slider)| Leans the peer to the left or right. Centred by default. It keeps the peer's stereo sound — it never folds them down to mono. -**Set peer EQ to default** (button)| Puts that peer's EQ sliders — both the 3-band and the 12-band — back to flat. It leaves the pan and volume alone. -**EQ mode** (picker)| Two choices: _3 band basic EQ_ or _12 band advanced EQ_. This chooses which set of band sliders you see below. -**EQ band sliders**| The sliders for whichever EQ mode is chosen. Each one runs from −12 dB to +12 dB, with flat (no change) in the middle. The 3-band has **Bass, Mids, Treble**. The 12-band has **31 Hz, 63 Hz, 80 Hz, 125 Hz, 250 Hz, 500 Hz, 1 kHz, 2 kHz, 4 kHz, 6 kHz, 8 kHz** and **16 kHz**. +**Set peer EQ to default** (button)| Puts that peer's EQ back to flat — all three modes at once (the 3-band, the 12-band and the parametric bands). It leaves the pan and volume alone. +**EQ mode** (picker)| Three choices: _3 band simple EQ_ , _12 band advanced graphic EQ_ or _16 band parametric EQ_. This chooses which EQ controls you see below. +**EQ controls**| For the two graphic modes, a set of sliders (see below). For the parametric mode, an Add band button and a list of your bands. Details follow. -The two EQ modes are kept separate. Switching between the 3-band and the 12-band keeps each one's own settings — nothing carries across from one to the other. +### The three EQ modes -Everything here updates in **real time** — you hear the change as you move a control — and it adds no extra delay to the audio. All of it (the two switches, and each peer's volume, pan and EQ) is saved with the profile. +**3 band simple EQ** and **12 band advanced graphic EQ** are graphic equalisers: a set of sliders at fixed frequencies, each running from −12 dB to +12 dB with flat (no change) in the middle. The 3-band has **Bass, Mids, Treble**. The 12-band has **31 Hz, 63 Hz, 80 Hz, 125 Hz, 250 Hz, 500 Hz, 1 kHz, 2 kHz, 4 kHz, 6 kHz, 8 kHz** and **16 kHz**. Each slider reads its level out in words, for example “plus 3 dB”, “minus 6 dB” or “flat”. + +**16 band parametric EQ** lets you place your own bands wherever you want them, up to sixteen. Instead of fixed sliders you build a list of bands: + + * Tab past the mode picker to the **Add band** button and press it. A small dialog opens with three boxes: a **start frequency** , an **end frequency** and a **gain in dB** (from −12 to +12). Each box you can type into or spin with the arrow keys; they only accept sensible numbers. As you change the values you hear the band on that peer straight away. Press **OK** to add it, or **Cancel** / **Escape** to drop it. + * Back on the tab, tab to the **Bands** list to hear your bands, one per row, each read out as its range and level — for example “200 Hz to 800 Hz, plus 3 dB”. The list is sorted low to high, so the bass bands are at the top and the treble at the bottom. + * To remove a band, land on it and press **Delete** , or use the **Delete band** button. You can select several at once (hold Shift and arrow, or hold Ctrl and arrow then Space to pick out individual ones) and delete them together. + + + +Each parametric band is a boost or cut spread across the range between its start and end frequencies. A wide range affects a broad sweep of the sound; a narrow one is more surgical. + +The three modes are kept separate. Switching between them keeps each one's own settings — nothing carries across from one to another. Only the mode you've picked is the one you hear. + +Everything here updates in **real time** — you hear the change as you move a control — and it adds no extra delay to the audio. All of it (the master switch's setting is saved, and each peer's tick, volume, pan and EQ) is stored with the profile. The one exception is the global “toggle everything” keyboard shortcut, which is machine-wide rather than per-profile. There's also a small EQ response graph on the tab; it's purely a visual picture of the shape you've dialled in and plays no part in how you use the tab with a screen reader. ## 10. ASIO and WASAPI @@ -783,19 +796,21 @@ Alt+I| Focus the Auto-tune latency interval combo box. It drives the timing for Alt+B| Focus Buffer smoothness Alt+A| Focus Artefact sound type -### Pan and EQ tab +### Volume, pan and EQ for peers tab -Only present when the Pan and EQ tab is switched on (tick “Show the Pan and EQ tab” on the General tab of Preferences). See Pan and EQ tab. +Present whenever the Volume, pan and EQ for peers tab is showing (it's shown by default; the toggle is “Show the volume, pan and EQ for peers tab” on the General tab of Preferences). See Volume, pan and EQ for peers tab. Key| Action ---|--- -Alt+E| Toggle Enable EQ for peers -Alt+P| Toggle Enable pan for peers -Alt+U| Focus the peer to shape +Alt+E| Toggle Enable volume, pan and EQ for all peers +Alt+U| Focus the Peers checklist Alt+L| Focus the Volume slider Alt+N| Focus the Pan slider Alt+Q| Set peer EQ to default Alt+M| Focus the EQ mode picker +Alt+A| Add band (parametric EQ mode only) +Alt+B| Focus the Bands list (parametric EQ mode only) +Alt+D| Delete band (parametric EQ mode only) ### File menu shortcuts (work from any tab) @@ -852,6 +867,7 @@ Send Windows global volume up to peers| Tell every connected peer to nudge their Send Windows global volume down to peers| The same, but lowering.| Unset Send Windows global mute toggle to peers| Tell every connected peer to toggle their Windows mute.| Unset Speak the RemSound status information| Read the whole status line out loud through your screen reader — the connection time, how many peers you have, whether sound is flowing, and how healthy the link is — from anywhere, even with RemSound in the tray. Just for screen-reader users; see Hearing the status on demand below.| Unset +Toggle volume, pan and EQ for all peers| Flip the one master switch on the Volume, pan and EQ for peers tab from anywhere, so you can drop all your per-person shaping in and out without leaving the app you're in. See that tab for what the switch does.| Unset You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key. @@ -1153,7 +1169,7 @@ Reached via **Record → Recording settings**. Two tickboxes sit at the top, the Control| What it does ---|--- **Split recording into separate tracks** (tickbox)| Instead of one mixed file, a recording becomes a _folder_ with one file per connected peer — each holding only that peer's sound — plus one file for your own send. Which of those files you get follows the Recording source choice below: _Received only_ gives you the peer files; _Both_ gives the peer files plus your own; _Sent only_ gives just your own. -**Bypass pan and EQ when recording** (tickbox)| Records the raw sound — before any volume, pan or EQ you've set on the Pan and EQ tab — even though you still hear the shaped version. Left off (the default), the recording captures what you actually hear, including your shaping; and on a split recording each peer's own file carries that peer's own shaping. +**Bypass pan and EQ when recording** (tickbox)| Records the raw sound — before any volume, pan or EQ you've set on the Volume, pan and EQ for peers tab — even though you still hear the shaped version. Left off (the default), the recording captures what you actually hear, including your shaping; and on a split recording each peer's own file carries that peer's own shaping. List| Shortcut| What goes in it ---|---|--- **Recording source**| Alt+S| Receive only / Send only / Both. See the source explanation above. diff --git a/_deploy/CUETools.Codecs.FLAKE.dll b/_deploy/CUETools.Codecs.FLAKE.dll new file mode 100644 index 0000000..1c7f0f4 Binary files /dev/null and b/_deploy/CUETools.Codecs.FLAKE.dll differ diff --git a/_deploy/CUETools.Codecs.dll b/_deploy/CUETools.Codecs.dll new file mode 100644 index 0000000..0a5243c Binary files /dev/null and b/_deploy/CUETools.Codecs.dll differ diff --git a/_deploy/Concentus.Oggfile.dll b/_deploy/Concentus.Oggfile.dll new file mode 100644 index 0000000..2c67543 Binary files /dev/null and b/_deploy/Concentus.Oggfile.dll differ diff --git a/_deploy/Concentus.dll b/_deploy/Concentus.dll new file mode 100644 index 0000000..d6fe0f4 Binary files /dev/null and b/_deploy/Concentus.dll differ diff --git a/_deploy/Mono.Nat.dll b/_deploy/Mono.Nat.dll new file mode 100644 index 0000000..980dcde Binary files /dev/null and b/_deploy/Mono.Nat.dll differ diff --git a/_deploy/NAudio.Asio.dll b/_deploy/NAudio.Asio.dll new file mode 100644 index 0000000..1a79fd0 Binary files /dev/null and b/_deploy/NAudio.Asio.dll differ diff --git a/_deploy/NAudio.Core.dll b/_deploy/NAudio.Core.dll new file mode 100644 index 0000000..00514d8 Binary files /dev/null and b/_deploy/NAudio.Core.dll differ diff --git a/_deploy/NAudio.Lame.dll b/_deploy/NAudio.Lame.dll new file mode 100644 index 0000000..7e4c6d6 Binary files /dev/null and b/_deploy/NAudio.Lame.dll differ diff --git a/_deploy/NAudio.Midi.dll b/_deploy/NAudio.Midi.dll new file mode 100644 index 0000000..b3c21d8 Binary files /dev/null and b/_deploy/NAudio.Midi.dll differ diff --git a/_deploy/NAudio.Wasapi.dll b/_deploy/NAudio.Wasapi.dll new file mode 100644 index 0000000..430379c Binary files /dev/null and b/_deploy/NAudio.Wasapi.dll differ diff --git a/_deploy/NAudio.WinForms.dll b/_deploy/NAudio.WinForms.dll new file mode 100644 index 0000000..d481c86 Binary files /dev/null and b/_deploy/NAudio.WinForms.dll differ diff --git a/_deploy/NAudio.WinMM.dll b/_deploy/NAudio.WinMM.dll new file mode 100644 index 0000000..313b671 Binary files /dev/null and b/_deploy/NAudio.WinMM.dll differ diff --git a/_deploy/NAudio.dll b/_deploy/NAudio.dll new file mode 100644 index 0000000..ae09ff5 Binary files /dev/null and b/_deploy/NAudio.dll differ diff --git a/_deploy/RemSound.Core.dll b/_deploy/RemSound.Core.dll new file mode 100644 index 0000000..5e50597 Binary files /dev/null and b/_deploy/RemSound.Core.dll differ diff --git a/_deploy/RemSound.Core.pdb b/_deploy/RemSound.Core.pdb new file mode 100644 index 0000000..3f03d04 Binary files /dev/null and b/_deploy/RemSound.Core.pdb differ diff --git a/_deploy/RemSound.Receiver.dll b/_deploy/RemSound.Receiver.dll new file mode 100644 index 0000000..af37bfa Binary files /dev/null and b/_deploy/RemSound.Receiver.dll differ diff --git a/_deploy/RemSound.Receiver.pdb b/_deploy/RemSound.Receiver.pdb new file mode 100644 index 0000000..366469f Binary files /dev/null and b/_deploy/RemSound.Receiver.pdb differ diff --git a/_deploy/RemSound.Sender.dll b/_deploy/RemSound.Sender.dll new file mode 100644 index 0000000..8a4e003 Binary files /dev/null and b/_deploy/RemSound.Sender.dll differ diff --git a/_deploy/RemSound.Sender.pdb b/_deploy/RemSound.Sender.pdb new file mode 100644 index 0000000..7522456 Binary files /dev/null and b/_deploy/RemSound.Sender.pdb differ diff --git a/_deploy/RemSound.deps.json b/_deploy/RemSound.deps.json new file mode 100644 index 0000000..4c80ebe --- /dev/null +++ b/_deploy/RemSound.deps.json @@ -0,0 +1,361 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": { + "RemSound/3.9": { + "dependencies": { + "CUETools.Codecs.FLAKE": "1.0.5", + "Concentus.Native.NetCore": "1.5.2", + "Concentus.Oggfile": "1.0.7", + "Mono.Nat": "3.0.4", + "NAudio": "2.3.0", + "NAudio.Lame": "2.1.0", + "RemSound.Core": "1.0.0", + "RemSound.Receiver": "1.0.0", + "RemSound.Sender": "1.0.0" + }, + "runtime": { + "RemSound.dll": {} + } + }, + "Concentus/2.2.2": { + "runtime": { + "lib/net8.0/Concentus.dll": { + "assemblyVersion": "2.2.2.0", + "fileVersion": "2.2.2.0" + } + } + }, + "Concentus.Native.NetCore/1.5.2": { + "runtimeTargets": { + "runtimes/linux-arm64/native/libopus.so": { + "rid": "linux-arm64", + "assetType": "native", + "fileVersion": "0.0.0.0" + }, + "runtimes/linux-armv6/native/libopus.so": { + "rid": "linux-armv6", + "assetType": "native", + "fileVersion": "0.0.0.0" + }, + "runtimes/linux-x64/native/libopus.so": { + "rid": "linux-x64", + "assetType": "native", + "fileVersion": "0.0.0.0" + }, + "runtimes/osx-arm64/native/libopus.dylib": { + "rid": "osx-arm64", + "assetType": "native", + "fileVersion": "0.0.0.0" + }, + "runtimes/osx-x64/native/libopus.dylib": { + "rid": "osx-x64", + "assetType": "native", + "fileVersion": "0.0.0.0" + }, + "runtimes/win-arm64/native/opus.dll": { + "rid": "win-arm64", + "assetType": "native", + "fileVersion": "0.0.0.0" + }, + "runtimes/win-x64/native/opus.dll": { + "rid": "win-x64", + "assetType": "native", + "fileVersion": "0.0.0.0" + }, + "runtimes/win-x86/native/opus.dll": { + "rid": "win-x86", + "assetType": "native", + "fileVersion": "0.0.0.0" + } + } + }, + "Concentus.Oggfile/1.0.7": { + "dependencies": { + "Concentus": "2.2.2" + }, + "runtime": { + "lib/net8.0/Concentus.Oggfile.dll": { + "assemblyVersion": "1.0.7.0", + "fileVersion": "1.0.7.0" + } + } + }, + "CUETools.Codecs/1.0.2": { + "runtime": { + "lib/netstandard2.0/CUETools.Codecs.dll": { + "assemblyVersion": "1.0.2.0", + "fileVersion": "1.0.2.0" + } + } + }, + "CUETools.Codecs.FLAKE/1.0.5": { + "dependencies": { + "CUETools.Codecs": "1.0.2" + }, + "runtime": { + "lib/netstandard2.0/CUETools.Codecs.FLAKE.dll": { + "assemblyVersion": "1.0.5.0", + "fileVersion": "1.0.5.0" + } + } + }, + "Mono.Nat/3.0.4": { + "runtime": { + "lib/net6.0/Mono.Nat.dll": { + "assemblyVersion": "3.0.0.0", + "fileVersion": "3.0.4.0" + } + } + }, + "NAudio/2.3.0": { + "dependencies": { + "NAudio.Asio": "2.3.0", + "NAudio.Core": "2.3.0", + "NAudio.Midi": "2.3.0", + "NAudio.Wasapi": "2.3.0", + "NAudio.WinForms": "2.3.0", + "NAudio.WinMM": "2.3.0" + }, + "runtime": { + "lib/net6.0-windows7.0/NAudio.dll": { + "assemblyVersion": "2.3.0.0", + "fileVersion": "2.3.0.0" + } + } + }, + "NAudio.Asio/2.3.0": { + "dependencies": { + "NAudio.Core": "2.3.0" + }, + "runtime": { + "lib/net8.0-windows7.0/NAudio.Asio.dll": { + "assemblyVersion": "2.3.0.0", + "fileVersion": "2.3.0.0" + } + } + }, + "NAudio.Core/2.3.0": { + "runtime": { + "lib/netstandard2.0/NAudio.Core.dll": { + "assemblyVersion": "2.3.0.0", + "fileVersion": "2.3.0.0" + } + } + }, + "NAudio.Lame/2.1.0": { + "dependencies": { + "NAudio.Core": "2.3.0" + }, + "runtime": { + "lib/netstandard2.0/NAudio.Lame.dll": { + "assemblyVersion": "2.0.1.0", + "fileVersion": "2.0.1.0" + } + } + }, + "NAudio.Midi/2.3.0": { + "dependencies": { + "NAudio.Core": "2.3.0" + }, + "runtime": { + "lib/netstandard2.0/NAudio.Midi.dll": { + "assemblyVersion": "2.3.0.0", + "fileVersion": "2.3.0.0" + } + } + }, + "NAudio.Wasapi/2.3.0": { + "dependencies": { + "NAudio.Core": "2.3.0" + }, + "runtime": { + "lib/netstandard2.0/NAudio.Wasapi.dll": { + "assemblyVersion": "2.3.0.0", + "fileVersion": "2.3.0.0" + } + } + }, + "NAudio.WinForms/2.3.0": { + "dependencies": { + "NAudio.WinMM": "2.3.0" + }, + "runtime": { + "lib/netcoreapp3.1/NAudio.WinForms.dll": { + "assemblyVersion": "2.3.0.0", + "fileVersion": "2.3.0.0" + } + } + }, + "NAudio.WinMM/2.3.0": { + "dependencies": { + "NAudio.Core": "2.3.0" + }, + "runtime": { + "lib/net6.0/NAudio.WinMM.dll": { + "assemblyVersion": "2.3.0.0", + "fileVersion": "2.3.0.0" + } + } + }, + "RemSound.Core/1.0.0": { + "runtime": { + "RemSound.Core.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "RemSound.Receiver/1.0.0": { + "dependencies": { + "Concentus": "2.2.2", + "NAudio": "2.3.0", + "RemSound.Core": "1.0.0" + }, + "runtime": { + "RemSound.Receiver.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + }, + "RemSound.Sender/1.0.0": { + "dependencies": { + "Concentus": "2.2.2", + "NAudio": "2.3.0", + "RemSound.Core": "1.0.0" + }, + "runtime": { + "RemSound.Sender.dll": { + "assemblyVersion": "1.0.0.0", + "fileVersion": "1.0.0.0" + } + } + } + } + }, + "libraries": { + "RemSound/3.9": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "Concentus/2.2.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-2B9YmHPKO+k7YpAAnqmiXwiMJnfjfj1C868RszOll2iZWLnGTAiC1q21L/d7CvTan7T+hyqU8dR0Dcy3cpjVfQ==", + "path": "concentus/2.2.2", + "hashPath": "concentus.2.2.2.nupkg.sha512" + }, + "Concentus.Native.NetCore/1.5.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-ry9TdS2Su3dOtWalTT6d0aJyx3NPQ/NjisotmAKxmgPdnhYpymZeZHZz4WnRA5Vw71yUnhj1lHqPQP71O5hHTg==", + "path": "concentus.native.netcore/1.5.2", + "hashPath": "concentus.native.netcore.1.5.2.nupkg.sha512" + }, + "Concentus.Oggfile/1.0.7": { + "type": "package", + "serviceable": true, + "sha512": "sha512-rKWtI5oiTWqK2ol1aWvIT1BRTPi07Uo1hAet0ysQoZQqFo/mxBRwr+ERw89fIdkL3tbMf3ncoW7b3HDGx8Sk4g==", + "path": "concentus.oggfile/1.0.7", + "hashPath": "concentus.oggfile.1.0.7.nupkg.sha512" + }, + "CUETools.Codecs/1.0.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-nWTrdvV8sBVkOcAxkARSMuLnbSJEYEZvPLJhsXKaBrac6lJyTNUyFuChB2/mIZiODJ1gPdDNoPx2yynI4TgKIA==", + "path": "cuetools.codecs/1.0.2", + "hashPath": "cuetools.codecs.1.0.2.nupkg.sha512" + }, + "CUETools.Codecs.FLAKE/1.0.5": { + "type": "package", + "serviceable": true, + "sha512": "sha512-s+ubq2xIG2M8vKgE4JRuTm4LxXhT0DlUlIAA5P00w72PxhliVJ+GOfntUXTlA7CU55cyV+evb3FbJBPuTdqNTA==", + "path": "cuetools.codecs.flake/1.0.5", + "hashPath": "cuetools.codecs.flake.1.0.5.nupkg.sha512" + }, + "Mono.Nat/3.0.4": { + "type": "package", + "serviceable": true, + "sha512": "sha512-oodXnwdcML4qUaZ+J44gaC/hn0n3uZHkvxScdt8NOcBbmbNmA7z1t5FEvUvn8cOnYSha8F4ZS57FJuXSKYhqdw==", + "path": "mono.nat/3.0.4", + "hashPath": "mono.nat.3.0.4.nupkg.sha512" + }, + "NAudio/2.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-xN+Lzlu9DmXqBiL6XkP0VZmgTxZPdSGjVSWtKG9HKuar3LhIYNOIYQETieigS+QDS+nrs1QmHbBWWOru0nWA5A==", + "path": "naudio/2.3.0", + "hashPath": "naudio.2.3.0.nupkg.sha512" + }, + "NAudio.Asio/2.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-I+rAAPT8vmSEw4d4ie+AoSkrvNK6ylRrXznnjQKS+qZgTA9Jnt1Pxe0EaU8nXxOOq5h8ucBSWccBEw6I/J0nkQ==", + "path": "naudio.asio/2.3.0", + "hashPath": "naudio.asio.2.3.0.nupkg.sha512" + }, + "NAudio.Core/2.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-jMd7r6dB6tAtXhOYL58ntPqwERNm1/Rhw5MKOIYvsnXzuX+PTGsa2VMam6n0npZYSwlSidKa4GAm4bFcXFUlcg==", + "path": "naudio.core/2.3.0", + "hashPath": "naudio.core.2.3.0.nupkg.sha512" + }, + "NAudio.Lame/2.1.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-Y+FtVgvjT+bNpBFqDiAKcFGelix1UavpX66vLymzWalXOGvY+MsoLNKL6aNGA0INjvBxeI3qd1LlD+bg7Nv2Ww==", + "path": "naudio.lame/2.1.0", + "hashPath": "naudio.lame.2.1.0.nupkg.sha512" + }, + "NAudio.Midi/2.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-t8wvPFPHHQOHhoNMCaDE8OiYZoJuxmY4H6UPBtmnf+xx7oQuajFCqlG4Q00ID+tiEyj471WeVb4Nkylr4tDoow==", + "path": "naudio.midi/2.3.0", + "hashPath": "naudio.midi.2.3.0.nupkg.sha512" + }, + "NAudio.Wasapi/2.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-y5K2BxrLnvohgu5znzg5wRsai3YcuNXfrpo68i7PmCQqpK5MC/R24e5aO3OyN/XhfRr12e0QYV0OnUWEHi5SzA==", + "path": "naudio.wasapi/2.3.0", + "hashPath": "naudio.wasapi.2.3.0.nupkg.sha512" + }, + "NAudio.WinForms/2.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-vyKqFUlAZrZ0QPcCM1T+zimjYgC1cNDloHaiOdtV4S1eK4AGu3Dz1EE4yy/WgcneJwQNjGFaELY7b2TsL+g56w==", + "path": "naudio.winforms/2.3.0", + "hashPath": "naudio.winforms.2.3.0.nupkg.sha512" + }, + "NAudio.WinMM/2.3.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-5G1dRjsZm50T3luyuqcmI2BSvj3K4ZJaD/x776/0Epj88qOsOryDZG40+MufwIk1UFJSFWhRobBqtJYFc8Ss4g==", + "path": "naudio.winmm/2.3.0", + "hashPath": "naudio.winmm.2.3.0.nupkg.sha512" + }, + "RemSound.Core/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "RemSound.Receiver/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "RemSound.Sender/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/_deploy/RemSound.dll b/_deploy/RemSound.dll new file mode 100644 index 0000000..fac8354 Binary files /dev/null and b/_deploy/RemSound.dll differ diff --git a/_deploy/RemSound.exe b/_deploy/RemSound.exe new file mode 100644 index 0000000..25b26d6 Binary files /dev/null and b/_deploy/RemSound.exe differ diff --git a/_deploy/RemSound.pdb b/_deploy/RemSound.pdb new file mode 100644 index 0000000..74713ba Binary files /dev/null and b/_deploy/RemSound.pdb differ diff --git a/_deploy/RemSound.runtimeconfig.json b/_deploy/RemSound.runtimeconfig.json new file mode 100644 index 0000000..e344539 --- /dev/null +++ b/_deploy/RemSound.runtimeconfig.json @@ -0,0 +1,20 @@ +{ + "runtimeOptions": { + "tfm": "net10.0", + "frameworks": [ + { + "name": "Microsoft.NETCore.App", + "version": "10.0.0" + }, + { + "name": "Microsoft.WindowsDesktop.App", + "version": "10.0.0" + } + ], + "configProperties": { + "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false, + "CSWINRT_USE_WINDOWS_UI_XAML_PROJECTIONS": false + } + } +} \ No newline at end of file diff --git a/_deploy/libmp3lame.32.dll b/_deploy/libmp3lame.32.dll new file mode 100644 index 0000000..61f0ffc Binary files /dev/null and b/_deploy/libmp3lame.32.dll differ diff --git a/_deploy/libmp3lame.64.dll b/_deploy/libmp3lame.64.dll new file mode 100644 index 0000000..89a5b1a Binary files /dev/null and b/_deploy/libmp3lame.64.dll differ diff --git a/_deploy/readme.html b/_deploy/readme.html new file mode 100644 index 0000000..3852f84 --- /dev/null +++ b/_deploy/readme.html @@ -0,0 +1,1312 @@ + + + + +RemSound user manual + + + + +

RemSound — user manual

+ +

RemSound is a Windows program that sends live sound from one computer to another, with very little delay. Picture a private audio link between two or more computers: each one decides what sound it wants to send and what sound it wants to play, and the audio travels straight between them over your network.

+ +

It was built for playing music together over the internet — a guitarist on one computer and a singer on another, hearing each other in real time — but it works just as well for listening to one room from another room in your house, co-hosting a podcast, or anything else where you want to get sound from one PC to another, fast.

+ +

Table of contents

+
    +
  1. What RemSound does
  2. +
  3. Quick start
  4. +
  5. Profiles
  6. +
  7. The main window: menu bar + three tabs
  8. +
  9. Menus (File, Record, Options, Help)
  10. +
  11. Connectivity tab
  12. +
  13. Audio inputs and outputs tab
  14. +
  15. Audio profile tab
  16. +
  17. ASIO and WASAPI
  18. +
  19. Peers — finding and connecting
  20. +
  21. How the network works (LAN, WAN, Tailscale)
  22. +
  23. Passwords and encryption
  24. +
  25. Latency and audio quality
  26. +
  27. Keyboard shortcuts
  28. +
  29. Global hotkeys (mute, volume, tray, recording)
  30. +
  31. Remote control: adjusting a peer's listening volume from your end
  32. +
  33. Startup behaviour
  34. +
  35. Audio cue sounds
  36. +
  37. Updating RemSound
  38. +
  39. Recording to a file
  40. +
  41. Logs and diagnostics
  42. +
  43. Command-line options
  44. +
  45. Troubleshooting
  46. +
  47. Glossary
  48. +
+ +

1. What RemSound does

+ +

RemSound carries sound from one PC's microphone or sound card to another PC's speakers or audio interface, almost instantly, over your network. Both computers run the same program, and each one decides for itself whether it wants to send sound, receive sound, or do both.

+ +

The basic flow

+ + + + + + +
StepWhat happens
1You tick “Send my audio” on the Audio inputs and outputs tab and choose which microphone or sound output you want sent.
2Your friend ticks “Receive audio” on the same tab and chooses which speakers or headphones should play the sound they receive.
3One of you ticks the other person in the Discovered peers list on the Connectivity tab (or types their address by hand).
4Sound starts flowing. The other direction works exactly the same way, on its own — both of you can speak at the same time.
+ +

There is no central server, no account, and nothing stored online. The sound goes straight from one computer to the other.

+ +
+RemSound on Android (receiver): there is a companion app that lets a phone or tablet receive RemSound audio — handy for listening on the move. It's a separate community project built and maintained by Aryan Choudhary, who is a screen-reader user himself and has tuned the app for TalkBack; it is not part of RemSound and is not maintained by us. Get the signed app from its releases page (download the latest app-release.apk): RemSound Android — Releases. +
+ +

2. Quick start

+ +

Let's assume you and a friend both have RemSound running, and that your two computers can reach each other on the network (the same Wi-Fi, the same Tailscale account, and so on).

+ +
    +
  1. Start RemSound. The first thing you'll see is the profile picker. On a brand-new install your only choice is New profile — select it and press Enter or click OK. Later, once you've saved a setup or two of your own, this is the dialog where you choose which one to load. See Profiles for the full story.
  2. +
  3. Once the main window opens, go to the Audio inputs and outputs tab. Tick Receive audio (Alt+R), then tick the device you want incoming sound played through in WASAPI outputs for received sound (Alt+3).
  4. +
  5. On the same tab, tick Send my audio (Alt+S) and tick your microphone in WASAPI inputs to send (Alt+5).
  6. +
  7. Go to the Connectivity tab, find your friend in the Discovered peers (Alt+D) list, and tick them. If they aren't showing up, use Add peer by IP (Alt+A) and type their address.
  8. +
  9. Have your friend do the same with you on their computer.
  10. +
  11. Within a second or two, both of you will hear each other.
  12. +
  13. In the File menu (Alt+F), choose Save as to give your setup a name. Next time you start the program, picking that name from the startup dialog restores all your settings, device choices, peers, and connections in one go.
  14. +
+ +
+If you have a professional audio interface (Audient, Komplete Audio, RME, Focusrite, and the like): on the Audio inputs and outputs tab pick your driver in the ASIO driver (Alt+D) list to use its low-delay channels. Choosing a real driver makes the ASIO device lists appear; choosing (none) hides them again and the app uses the ordinary Windows sound path only. See ASIO and WASAPI below. +
+ +

3. Profiles

+ +

RemSound saves your whole setup — which devices are ticked, whether you're sending or receiving, your sound quality settings, delay targets, hotkeys, your ASIO driver choice, remembered peers, currently connected peers — into a single settings file. Each saved setup is called a profile. You choose which profile to load every time you start the program. You might keep one profile for “morning podcast” and another for “evening jam session”, each with a different mix of devices ticked, and switch between them in a couple of clicks.

+ +

The startup picker

+ +

When RemSound starts, the first thing you see is the profile picker. It is a list of your saved profile names, with an extra entry called New profile at the top. The keys are deliberately simple:

+ + + + + + + + + + +
KeyAction
Up / DownMove between profiles in the list.
EnterLoad the highlighted profile (or a new profile) and open the main window.
OK buttonSame as Enter.
DelDelete the highlighted profile, after a yes / no confirmation.
Browse… buttonChoose a different folder to read profiles from. Handy if you keep your profiles in Dropbox or another sync folder so they follow you between computers. Your choice is remembered next time RemSound starts.
EscDeliberately does nothing here. You have to pick a profile to start the program.
Alt+F4Closes the dialog and quits RemSound — in other words, “I don't want to start the program right now.”
+ +

The first profile in the list is highlighted to begin with, so on a fresh install where the only entry is New profile, you just press Enter to get going.

+ +

What “New profile” means

+ +

A new profile is a one-off session with all the defaults: nothing ticked in any device list, neither Receive nor Send turned on, the standard sound settings, no ASIO driver chosen, no remembered peers, and the standard hotkeys. You'd pick it for a quick session you don't plan to save, or as a clean starting point for a new profile. The Save button is hidden while you're on a new profile — there's no existing setup to update, only a new one to save.

+ +

Starting a new profile later: the picker only appears at startup, and if you've set RemSound to start in a specific profile you skip straight past it. So to start a brand-new profile at any time, use File → New profile (Ctrl+N, or Alt+F, W) — it opens a fresh, default session, ready for you to set up and then Save as. If your current profile has unsaved changes, it offers to save them first.

+ +

Saving and updating

+ +

The File menu has two ways to save:

+ + + + + +
ItemWhat it does
File → Save (Ctrl+S)Updates the current profile with whatever your settings are right now. If you're on a new profile, this turns into Save as instead, because there's no existing profile to update.
File → Save as… (Alt+F, A)Always available. Asks you for a name. From a new profile, this is how you create your first profile. From an existing profile, it makes a fresh copy under a new name and switches to that copy.
+ +

The window title bar always shows which profile is in use: RemSound — Active profile: My session name.

+ +

Switching, renaming, and deleting

+ + + + + + +
ActionHow
Switch to a different profileFile → Open profile (Alt+F, O). Pick a profile in the file picker. RemSound reloads using that profile.
Rename the current profileFile → Rename current profile (Alt+F, M). It asks for the new name and renames the profile's file; the window title updates straight away.
Delete a profileFile → Open profile, then right-click the entry in the Windows file picker and choose Delete. RemSound lets Windows handle this rather than having its own delete button.
+ +

Where your files are stored

+ +

Everything RemSound keeps for you on this computer — your settings, your profiles, your logs, and your cue sounds — lives together in one folder inside RemSound called user settings and logs. Each profile is one small file, stored at:

+ +
<RemSound folder>\user settings and logs\profiles\<your computer name>\<profile name>
+ +

(If you're upgrading from an older version, RemSound moves all of this into the user settings and logs folder automatically the first time you run this version, and tells you once that it's done it. Nothing is lost.) From this version on, RemSound updates never touch that folder — so anything of your own in there, including custom cue sounds, stays safe when you update.

+ +

The folder named after your computer keeps each machine's profiles separate. If you used the Browse… button on the startup dialog to pick a different folder (for example, one inside Dropbox), the profiles are stored directly in that folder — with no per-computer subfolder — so two computers pointed at the same shared folder see exactly the same list.

+ +

You can also copy a profile file from one computer to another: drop it into the other computer's profile folder and it will appear in that computer's startup dialog. If the other computer doesn't have the same equipment (different sound cards, different ASIO drivers), those device choices are simply skipped when the profile loads — RemSound won't show an error or a warning, the relevant lists just won't have those items ticked.

+ +
+Tip: profile files are plain text and readable by people. If you ever want to change something by hand (for example, a hotkey) without opening the app, you can open the file in any text editor. +
+ +

What is NOT saved in a profile

+ +

A few things are deliberately kept out of profiles:

+ + +

Locking a profile (read-only)

+ +

By default, RemSound treats your profile like a document: if you change something while it's running, you'll be asked “save changes?” when you exit. Most of the time that's exactly what you want — you don't lose work by accident.

+ +

But sometimes you want the opposite. You have a profile you live in every day, you toggle send or receive on or off during the day as a matter of course, and you don't want to be asked about saving every single time you close RemSound. You especially don't want to be asked if RemSound might close itself for some other reason (a Windows update, your screen reader crashing, a remote session dropping, a laptop going into hibernate) — because then there's a save prompt sitting on screen that nobody can dismiss, and the app can't actually close.

+ +

Locking the profile solves this. When ticked:

+ + + +

How to lock or unlock: open the File menu (Alt+F) and pick Lock profile (read-only) (Alt+F, L). It's a tickable menu item — pick it once to turn the lock on (a tick appears next to it); pick it again to turn the lock off (the tick disappears). The lock state is remembered with the profile, so closing and reopening RemSound keeps the profile locked exactly as you left it.

+ +

Saving on purpose while a profile is locked

+ +

The lock is there to stop accidents — it doesn't stop you saving when you mean to. If you press Save (Ctrl+S) or pick File → Save on a locked profile, RemSound shows a one-time warning explaining what's about to happen:

+ +
+

Saving onto a read-only profile. You're about to save changes onto a profile that's marked as read-only. RemSound allows this because you asked to save on purpose — the lock only stops the automatic “save your changes?” prompt; it doesn't stop you saving when you mean to.

+

Click Save anyway to overwrite this profile, or Cancel and use File → Save as… if you'd rather save your changes to a new profile.

+
+ +

There's a Do not show me this message again tick on the warning. Once you tick it, future deliberate saves on a locked profile go through silently without the warning. The setting is per-machine, not per-profile — tick it once and it applies on every locked profile from that point on.

+ +

So in summary, on a locked profile:

+ + +
+If a save prompt is blocking your shutdown right now: close it by pressing Esc (or click Cancel if you can see it), unlock by going File → Lock profile (read-only), then close RemSound. From this launch forward there'll be no prompt. +
+ +

4. The main window: menu bar + three tabs

+ +

The main window has three parts, stacked top to bottom:

+ +
    +
  1. A menu bar at the top with four menus — File, Record, Options and Help. See Menus.
  2. +
  3. A row of tabs with three tabs — Connectivity, Audio inputs and outputs, Audio profile. Each tab has its own Alt+letter shortcuts that only work when that tab is the one showing — so the same letter can do different things on different tabs without clashing.
  4. +
  5. A status line at the bottom that updates once a second with how long you've been connected, how many peers you have, whether sound is flowing, and connection health.
  6. +
+ + + + + + +
TabWhat it's for
ConnectivityConnected, discovered and remembered peers. Adding a peer by address. A connection status read-out.
Audio inputs and outputsThe ASIO driver picker (when an ASIO driver is installed), the Receive audio and Send my audio checkboxes, and all the device lists. Choosing a real driver in the picker brings up the ASIO device lists alongside the ordinary Windows ones; choosing (none) hides them.
Audio profileCodec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into an Audio send parameters group and an Audio receive parameters group.
+ +

The system tray icon and its menu

+ +

When RemSound is minimised to the tray (via File → Minimise to tray, the “Show or hide window” global hotkey, or by starting minimised on launch), the main window hides and an icon appears in your Windows system tray (the small icons cluster next to the clock).

+ +

Hovering over the tray icon shows a short summary of what RemSound is doing right now — the number of healthy peers, whether you're sending or receiving and in which mode (WASAPI, ASIO, or both), and whether a recording is running. The summary keeps itself up to date as things change. It tells you a recording is in progress, but not its exact length — for that, glance at the main window. Examples:

+ + + +

Right-clicking the tray icon opens a small menu with everything you might want to reach without re-opening the main window:

+ + + + + + + + +
ItemShortcutWhat it does
Show RemSoundWBrings the main window back to the front and gives it focus. Double-clicking the tray icon does the same thing.
Enable sending (tickable)SToggles “Send my audio” on or off, the same way as the checkbox on the Audio inputs and outputs tab. The tick reflects the current state — ticked means sending, unticked means not.
Enable receiving (tickable)RToggles “Receive audio” on or off. Same tick-reflects-state rule.
Profiles →PA submenu listing your recent profiles, most recent first. Each row has a single-digit shortcut: while the submenu is open, press 1 for the most recent, 2 for the next, and so on up to 5. Selecting one switches the active profile, exactly the same way as the File menu's Recent profiles submenu. Greyed out as “(No recent profiles)” when you haven't loaded any yet.
ExitXCloses RemSound entirely.
+ +

Keyboard access: the tray icon is reachable through standard Windows shortcuts — Windows + B moves focus to the notification area, arrow keys navigate, Enter activates, and the application context-menu key (or Shift+F10) opens the right-click menu without a mouse.

+ +

Important warnings always come to the front. Even when RemSound is hidden in the tray, a warning it needs you to read — such as the “your files have moved” notice, a microphone-blocked warning, or an update prompt — pops up in front of whatever you're doing, with focus, so your screen reader reads it straight away. RemSound stays in the tray; only the warning comes forward.

+ +

Only one copy of RemSound runs at a time

+ +

RemSound only ever runs as a single copy. If you try to open it while it's already running — for example by double-clicking it when it's already sitting in the system tray — it won't start a second one. Instead it asks what you'd like to do:

+ + + + + +

There are four menus on the main window: File (Alt+F), Record (Alt+K), Options (Alt+O) and Help (Alt+H). The Record menu opens with Alt+K rather than Alt+R because Alt+R is already used by the Receive audio checkbox on the main window. The menu's title is shown as “Record (Alt+K)” so you can find the shortcut even though there is no K in the word.

+ +

File menu

+ +

The File menu holds everything to do with profiles — opening, saving, renaming — plus minimising to the tray and exiting.

+ + + + + + + + + + + + +
ItemShortcutWhat it does
New profileCtrl+N, or Alt+F, WStarts a brand-new profile from scratch — a fresh, unsaved session (everything unticked, nothing connected, default settings). This is how you create a profile for a different setup at any time, even when RemSound is set to start straight into a specific profile and you never see the picker. If your current profile has unsaved changes, it offers to save them first. Once you've set things up, use Save as to give the new profile a name.
Open profile…Ctrl+O, or Alt+F, OOpens a Windows file picker showing your profiles folder. Pick a profile, and RemSound reloads using it (the window closes and reopens with all that profile's device choices, peers and settings restored). To delete a profile, right-click its entry inside the file picker and choose Delete — that lets Windows handle the deletion.
Recent profiles →Alt+F, RA submenu listing the last five profiles you've opened, most recent first. Each row has a single-digit shortcut: while the submenu is open, press 1 for the most recent, 2 for the next, and so on up to 5. Or just select the one you want. It reloads the profile the same way Open profile does. If a recent profile's file has been deleted or moved away, it's left out of the submenu (it stays in the list in case the file comes back later — for example when you reconnect an external drive). If the list is empty, you see a greyed-out “(No recent profiles)” entry. The same list appears in the system-tray icon's Profiles submenu, with the same number shortcuts, so you can switch profiles without re-opening the main window.
SaveCtrl+SUpdates the current profile with your current settings. If there's no current profile (you're on a new profile), this becomes Save as automatically.
Save as…Alt+F, AAsks for a name and saves a copy. Use it to save your current setup under a new name, or to save for the first time from a new profile.
Rename current profile…Alt+F, MRenames the current profile's file and updates the window title. Does nothing on a new profile (there's no profile to rename).
Lock profile (read-only) (tickable)Alt+F, LWhen ticked, the current profile is loaded for use but RemSound will not save any of your changes back to it. The window title shows “(read-only)” so you can tell at a glance. Save (Ctrl+S) politely refuses with a hint to use Save as instead, and closing RemSound never asks “save changes?” — it just closes. Anything you've changed during the session is forgotten when RemSound closes; the file on disk is left exactly as it was. The lock setting is saved on the profile itself, so it sticks across launches. See Locking a profile for the full story.
Minimise to trayAlt+F, NHides the window down to the system tray (the small icons near the clock). The tray icon's hover summary tells you what RemSound is doing, and right-clicking it gives you Show RemSound, Enable sending, Enable receiving, your Profiles submenu, and Exit — see The system tray icon and its menu for the full rundown. To bring the window back, double-click the tray icon, pick “Show RemSound” from its menu, or use the “Show or hide window” global hotkey (set in the Keyboard shortcuts dialog, default Ctrl+Shift+F10).
ExitAlt+F, X (or Alt+F4)Closes RemSound. If you have unsaved profile changes (and the profile isn't locked), it asks you first.
+ +

Record menu

+ +

The recording feature can save what you're sending, what you're receiving, or both, to a file on your computer as a WAV, MP3, OGG-Opus or FLAC file. See Recording to a file for the full chapter; this is just the menu summary.

+ + + + + + +
ItemShortcutWhat it does
Start recording / Stop recordingCtrl+R, or Alt+K, RA toggle. The label switches between “Start recording” and “Stop recording” to show whichever action the next press would do. Either label is activated by the letter R. Each time you start, RemSound plays a short cue sound (if you've enabled it in Preferences), then creates a new file in your recordings folder with a name like RemSound-2026-05-19_14-30-00 and the right file extension. Stopping closes the file and plays the stop cue. Ctrl+R works from anywhere in the main window.
Open current recordings folderAlt+K, OOpens your recordings folder in Windows File Explorer. It creates the folder if it doesn't exist yet (which happens the first time on a fresh install).
Change recordings folder…Alt+K, CA folder picker. Choose a different folder for future recordings. The choice is saved in the current profile, so different profiles can record to different places.
+ +

Options menu

+ +

The Options menu gathers everything you might want to configure about the app — recording settings, keyboard shortcuts, startup behaviour, and general preferences.

+ + + + + + + +
ItemShortcutWhat it does
Recording settings…Alt+O, SOpens the Recording settings dialog. Up to five lists: Recording source (Alt+S), File format (Alt+F), Audio format attributes (Alt+A), FLAC compression level (Alt+L — only shown when FLAC is chosen), and Channels (Alt+C). The attributes list changes to match the format you pick. OK saves to the current profile; Cancel discards.
Keyboard shortcuts…Ctrl+K, or Alt+O, KOpens the global hotkey dialog (mute, volume, show/hide window, start/stop recording, remote-control commands).
Startup behaviour…Alt+O, TOpens the Startup behaviour dialog. Choose whether to launch automatically with Windows, which profile to load by default, and whether to start hidden in the tray.
Preferences…Ctrl+P, or Alt+O, POpens the Preferences dialog, organised into four tabs (move between them with Ctrl+Tab, or the arrow keys when the tab names have focus): General — the profiles folder, accept remote volume commands, UPnP router opening, and keep-logs / write-logs-now; Audio cues — the cue list and its sounds (see Audio cue sounds); Startup behaviour — start minimised / with Windows / with a specific profile; and Update settings — the update checks and install options. Esc or the Close button dismisses it.
+ +

Help menu

+ +

The Help menu opens this manual, checks for updates, and shows the About dialog.

+ + + + + + +
ItemShortcutWhat it does
HelpF1 (anywhere), or Alt+H, HOpens this user manual in your default web browser. F1 also works from inside every dialog (Preferences, Keyboard shortcuts, About, Startup behaviour) and from the startup profile picker, before the main window has even loaded.
Check for updatesAlt+H, CAsks the RemSound website whether a newer version is available. If there is one, you get a confirmation dialog with the release notes and a Yes / No to install. If you're already up to date, a popup tells you so. (To have RemSound check on its own instead of pressing this button, see Updating RemSound.)
About RemSoundAlt+H, AA small dialog showing the version you're running and the latest release notes in a scrollable read-only box. Close (or Esc) dismisses it.
+ +

6. Connectivity tab

+ +

This is where you manage peers and reach the logging options. The controls on this tab, in tab order:

+ + + + + + + + +
ControlShortcutWhat it does
Connected peersAlt+CThe people you currently have sound flowing with. Unticking a row disconnects that peer.
Discovered peersAlt+DPeople RemSound has heard from in the last few seconds. Tick someone to connect to them.
Remembered peersAlt+RPeople you've connected to before, or added by address. This list is kept between sessions. Tick someone to reconnect.
Add peer by IPAlt+AOpens a small box where you type an address or computer name. It adds that peer to the remembered list and connects.
Connection statusAlt+SA read-only box of text that sums up everything happening right now — how long you've been connected, how many peers you have, how much sound is flowing each way, and the connection health of each peer. Open it to read the current connection status.
+ +

7. Audio inputs and outputs tab

+ +

This tab controls everything to do with which sound devices are involved. The ASIO driver picker at the top decides whether ASIO is being used at all. The Receive side and the Send side each have their own master checkbox and their own device lists.

+ + + + + + + + + + + + +
ControlShortcutWhat it does
ASIO driverAlt+DA list that starts with (none). Pick (none) and the app uses the ordinary Windows sound path only; pick a real driver and the ASIO device lists appear below, and the Audio profile tab gains a second delay setting. If your computer has no ASIO drivers installed, this control is hidden completely.
Receive audioAlt+RThe master switch for receiving. When it's off, no sound plays out, no matter which output devices are ticked.
WASAPI outputs for received soundAlt+3Tick which ordinary Windows outputs (speakers, headsets) should play the received sound. Ticking more than one means the received sound plays out of all of them at once.
ASIO outputs for received soundAlt+1(Shown when an ASIO driver is chosen.) Tick which ASIO channel pairs should play the received sound.
Set volume for all received audioAlt+VA slider: the master volume for everything coming in. There is no separate volume per device or per person.
Send my audioAlt+SThe master switch for sending.
WASAPI outputs to sendAlt+4Tick which Windows output devices to capture from — this captures whatever is currently playing on those speakers and sends it.
WASAPI inputs to sendAlt+5Tick which Windows input devices to capture (microphones, line-ins).
ASIO inputs to sendAlt+2(Shown when an ASIO driver is chosen.) Tick which ASIO channel pairs to capture and send.
+ +

All the device lists are checkable lists — tick or untick an item to include or exclude that device. Profiles save which devices are ticked; a new profile starts with everything unticked.

+ +

Receiving

+ +

To receive sound you need two things: Receive audio ticked, and at least one output device ticked. Without an output device, even when sound arrives there is nowhere for it to go.

+ +

Tick as many outputs as you like across the WASAPI and ASIO output lists — the same received sound plays out of all of them. Common combinations:

+ + +

Hearing everyone with one output ticked. A peer always tells you which sound path it's sending from. On your end, if you only have one type of output device ticked, sound from a peer using the other type is still routed through whatever output you do have ticked. So a single ticked output is enough to hear everyone.

+ +

Sending

+ +

To send sound you need Send my audio ticked, plus at least one capture source ticked across the three send lists.

+ + + + + + +
ListWhat it capturesTypical use
WASAPI outputs to sendWhatever Windows is currently playing through that output. So picking your “Speakers” device captures whatever you're hearing.Sharing music playback, sharing the sound from a video call, anything coming out of your own speakers.
WASAPI inputs to sendSound captured straight from a microphone or line input.Your USB microphone, a headset mic, a line-in.
ASIO inputs to sendAn ASIO channel pair — usually a hardware input on a professional audio interface.An instrument input on an Audient EVO, a microphone preamp on a Focusrite, and so on.
+ +

Tick any combination across the three lists. RemSound mixes them together into one stream and sends that to all your chosen peers. So you can send a mic plus a guitar plus your system sound all at once, mixed together, and your friends hear all three.

+ +
+Capturing your speakers can cause an echo loop. If you tick the same device both in “WASAPI outputs to send” and in “WASAPI outputs for received sound”, then the received sound plays out of that device, gets captured again, and gets sent back. The other person ends up hearing their own voice on a delay. Don't tick the same device on both sides at once. +
+ +
+If your microphone sends silence: Windows can block desktop apps from using the microphone, and when it does, RemSound's mic capture still switches on but only sends silence — so you look like you're sending, but the other person hears nothing. RemSound watches for this: when you tick a microphone in WASAPI inputs to send while Windows is blocking it — or load a profile that already has one ticked — a message pops up telling you, with the exact two settings to turn on — open Windows Settings → Privacy & security → Microphone, then turn on both Microphone access and Let desktop apps access your microphone. The check also catches the sneakier kinds of block: one aimed at RemSound alone in that same Settings page's per-app list, and one set by an administrator or workplace policy — that last kind doesn't show up as a switch you can flip, so if the warning says a policy is involved, it needs whoever manages the computer to lift it. (ASIO inputs aren't affected, because ASIO talks straight to the hardware and bypasses that Windows privacy gate.) It doesn't change anything you receive — only sending your own mic. +
+ +

8. Audio profile tab

+ +

Everything that shapes the trade-off between sound quality and delay lives here. The first control on the tab is the priority mode checkbox — it sits on its own at the top because it has the biggest single effect on how the audio feels in the first few seconds. Below it are two groups: Audio send parameters first, then Audio receive parameters.

+ +

Use CPU and Windows performance settings in high priority mode (Alt+U)

+ +

This is the first control on the tab. When it's ticked, RemSound asks Windows to keep it running at full speed the whole time RemSound is open under this profile.

+ +

The effect is that the “the first few seconds sound rough, then it warms up” behaviour goes away — nothing in the system is allowed to coast while RemSound is sitting quietly between bursts of sound.

+ +

This is a per-profile setting, so you can have one profile for live sessions where it's on, and another for casual background listening where it stays off. Turning it on or off marks the profile as having unsaved changes; save the profile to keep your choice.

+ + + + +
When to tick itWhen to leave it off
Playing music together live. Anything where the first few seconds matter. Professional setups using ASIO at very low delay targets (under 15 ms). Sessions where the computer sits idle between short bursts of sound.A laptop running on battery, especially for a long session. Background listening for hours at a time. A passive monitoring setup that doesn't need a fast start.
+ +

The cost on a desktop is a couple of extra watts while RemSound is open. The cost on a laptop running on battery is that the battery drains a bit faster over the session, because the processor stays more wakeful instead of dozing — RemSound's own workload doesn't change, the processor just doesn't sleep as deeply. The setting is reversed automatically when RemSound closes (or when you untick it), so it's fine to leave the app running with the box ticked for a whole session, and turning it off partway through works too.

+ +

None of the other apps on your computer are affected. RemSound only asks Windows to keep itself running at full speed; Windows still saves power on everything else as normal, so your screen reader, browser and background programs are untouched.

+ +

Audio send parameters

+ + + + + + +
ControlShortcutWhat it does
Audio codecAlt+CThe codec is the method RemSound uses to package the sound before sending it. Three choices: PCM 48k 24-bit (uncompressed), Opus broadcast quality (loss tolerant), or Opus live latency (for jamming and monitoring). See codec choice.
Packet sizeAlt+PStandard (the default) or Small (for a local network only). Smaller packets save a couple of milliseconds of delay on the sending side, but they double how many packets are sent.
Lock to audio clockAlt+DA timing setting on the sending side. It ties the sending of packets to the sound device's own hardware clock, which removes a little jitter (jitter means uneven packet timing). Brief clicks are possible if the connection can't keep up. The label changes depending on whether ASIO is in use, so it always describes what it does in your setup.
+ +

Audio receive parameters

+ +

What you see in this section depends on whether an ASIO driver is chosen on the Audio inputs and outputs tab. With no ASIO driver, you see one delay setting (labelled simply “Audio latency”). With an ASIO driver chosen, you see two delay settings — one for each sound path — each with its own auto-tune toggle. The two paths are independent: a problem on one doesn't affect the other.

+ + + + + + + + + +
ControlShortcutWhat it does
ASIO latency in millisecondsAlt+L(Only when an ASIO driver is chosen.) A small up/down number control. It sets the target amount of sound to keep buffered for the ASIO path. Default 10 ms. ASIO can sustain very low values, but going below the network's real-world jitter level (typically 15–25 ms) causes constant tiny corrections that you can hear — pick 25 ms as a safe floor unless both computers are on the same wired network or the same machine.
Continuous auto-tune ASIO latencyAlt+T(Only when an ASIO driver is chosen.) A checkbox. It nudges the ASIO delay target as the ASIO path's jitter changes. It works independently of the WASAPI toggle.
WASAPI latency in milliseconds (called just “Audio latency” when there's no ASIO driver)Alt+W (Alt+L when no ASIO driver)A small up/down number control. It sets the target amount of sound to keep buffered for the WASAPI path (or the only path, in WASAPI-only setups). Smaller means less delay but more clicks. Most people want 20–80 ms.
Continuous auto-tune WASAPI latency (called “Continuous auto-tune latency” when there's no ASIO driver)Alt+Y (Alt+T when no ASIO driver)A checkbox. When it's on, RemSound nudges the WASAPI delay value automatically as the network changes. The companion interval combo box (Alt+I) sets how often it re-checks: 3, 5, 10, 15, or 30 seconds. The combo's label is “Auto-tune latency interval” in WASAPI-only setups and “Auto-tune interval — WASAPI and ASIO” when an ASIO driver is chosen, because that one timer drives both paths' auto-tuning. Each path still settles at whatever target its own calculation chooses; only the timing of the re-checks is shared.
Buffer smoothnessAlt+BA list, 1 to 10. It controls how patient the receiving side is with sound that arrives late, on either path. Higher means more protection from clicks but a longer steady delay. Default 3.
Artefact sound typeAlt+AA list. Noise burst (the default) fills a momentary gap with a brief soft hiss, which blends into music. Click leaves the gap unfilled so you hear an obvious click — useful when you want to hear every problem.
+ +

Most people only need to pick a codec and a smoothness level, and leave everything else at its default.

+ +

9. ASIO and WASAPI

+ +

RemSound can use two different ways of handling sound. Which one it uses depends on the ASIO driver (Alt+D) list at the top of the Audio inputs and outputs tab.

+ +

WASAPI (the default)

+

WASAPI is the normal Windows way of handling sound — every speaker and microphone in your Windows sound settings works this way. The delay added by capturing or playing through WASAPI is usually 10–30 milliseconds. Everyone running RemSound has WASAPI; no special equipment is needed.

+ +

ASIO (needs a driver)

+

ASIO is a faster, more direct way of handling sound used by professional audio equipment. ASIO drivers talk straight to the hardware, giving a hardware delay of under 5 milliseconds. It only works if your audio interface came with an ASIO driver.

+ +

The ASIO driver picker doesn't appear at all on a computer with no ASIO drivers installed. Common drivers that do appear:

+ + +

How the driver picker decides

+ +

One control, two outcomes:

+ + + + + +
ASIO driver choiceWhat happensDelay
(none)WASAPI captures and plays the sound directly. ASIO is not used at all.About 10–30 ms. The lowest possible for anyone without an ASIO driver.
Any real driver nameWASAPI and ASIO both run, side by side, as two independent streams. Each keeps its own native delay — ASIO stays under 5 ms even while WASAPI is also running.WASAPI at its rate, ASIO at its rate. Each one has its own delay setting on the Audio profile tab (see Latency).
+ +

On a fresh install the choice is (none). If you have an ASIO driver and want to use it, select it in the picker. To go back to WASAPI only, select (none).

+ +

ASIO channel pairs

+

ASIO doesn't list “devices” the way Windows does. Instead it gives you a list of channels (usually 2, 4, 6, 8 or more, depending on the interface), grouped into stereo pairs. RemSound labels each pair with the driver name, the pair number, and the channel names the driver itself reports. For an Audient EVO 8 you'd see entries like:

+ +
+Audient USB Audio ASIO Driver — Pair 1 (channels 1/2): Mic | Line | Instrument 1 / Mic | Line 2
+Audient USB Audio ASIO Driver — Pair 2 (channels 3/4): Mic | Line 3 / Mic | Line 4
+Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / Loop-back 2 (R)
+
+ +

Buffer size for ASIO

+

RemSound has no buffer-size control of its own. To change the ASIO buffer size, open the control panel program that came with your audio interface (such as NI's Komplete Audio Control Panel or the Audient EVO software) and set it there. The driver remembers its buffer size between sessions; RemSound simply uses whatever the driver is set to.

+ +
+About Realtek ASIO: if you see “Realtek ASIO” in the driver list, be careful with it. Despite the name, it isn't tied to Realtek hardware — it's a generic driver that opens whatever Windows treats as the default sound device. On a computer that has a real audio interface (Audient, Komplete, and so on), choosing Realtek ASIO will often grab that interface and end up fighting both your real ASIO driver and your screen reader for the same hardware. It's usually best to ignore Realtek ASIO completely. +
+ +
+RemSound watches for it for you (new in v3.4): if a Realtek ASIO driver is installed, RemSound spots it on startup and offers, just once, to disable it — partly for the device-grabbing reason above, and partly because it leaks Windows resources every time it's opened. Say yes and RemSound adds it to a never-touch list and takes it out of the driver picker, so it can't be chosen by accident. You can reverse that — or disable it later if you kept it — any time from Options → Enable / Disable Realtek ASIO driver in RemSound. Once you've answered the startup question, RemSound won't ask again. +
+ +

Same driver, sending and receiving, on one computer

+

RemSound supports this — you can capture from your audio interface and play received sound out of the same interface at the same time, on the same computer. Most modern professional audio drivers handle this fine.

+ +

10. Peers — finding and connecting

+ +

A “peer” is another computer running RemSound that you want to talk to. You manage peers on the Connectivity tab. It has three lists, all of them checkable:

+ + + + + + +
ListContentsWhat ticking does
Connected peersPeople you currently have sound flowing with.Unticking disconnects.
Discovered peersPeople RemSound has heard from in the last few seconds — either from an announcement sent across your local network, or from a direct announcement (which is how it works over Tailscale and other VPNs).Connects you to that peer. Sound starts flowing both ways.
Remembered peersPeople you've connected to before, plus any addresses you've typed in by hand. This list is kept between sessions.Connects to that remembered peer if they're online (and adds them as a manual connection if discovery hasn't found them yet).
+ +

There's also the Add peer by IP (Alt+A) button, which opens a small box for a computer name or address. It's useful for a first connection over a VPN, where discovery hasn't reached the other computer yet.

+ +

Connecting to one specific IP address (and only that one)

+

RemSound can reach another computer two ways, and the difference matters if a computer has more than one IP address:

+ +

So to force a connection to one specific IP — say a machine that appears under two addresses and you only want one of them — add it by that IP and tick only that entry. If the same machine also turns up in Discovered peers under its other address, it shows as a separate entry that you simply leave unticked; RemSound keeps the two apart by their actual address, so they never merge.

+

Either way, the profile remembers exactly what you ticked. Tick a discovered name and the profile reconnects by that name next time; add and tick an IP and the profile reconnects to that exact IP. So the way to make a profile always use one specific IP is simply to add and tick it by that IP, then save the profile.

+ +

You only hear peers you've ticked

+

Even if a peer is sending sound your way, you won't hear it until you've ticked their checkbox. This is deliberate — connecting is a step where you give your consent. A peer's name appears in Discovered the moment they come online, but they can't make any sound on your speakers until you say yes.

+ +

Connection health

+

For each connected peer, the status read-out at the bottom of the window shows a small health note: the latest round-trip time in milliseconds, or pending, stale or unreachable if the regular check-in messages have stopped. (Round-trip time is how long sound takes to travel to the other computer and back.) RemSound plays a connect cue (a short sound) when a peer becomes healthy and a disconnect cue when one becomes unreachable. You can silence both cues using Audio cue sounds in the Preferences dialog (Options → Preferences, or Ctrl+P).

+ +

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

+ +

RemSound communicates on two network channels:

+ + + + + +
ChannelPurposeDefault
AudioThe actual sound, sent straight from one computer to the other. The regular health check-ins use this same channel too — one channel, one firewall rule.47830
Discovery“I'm here” announcements every 1.5 seconds, so peers can find each other.47831
+ +

One audio channel number is used for everything — Tailscale, local network connections, and any relay server. You never need to type a channel number after an address; the default is assumed. Both sides of a connection do need to use the same audio channel number.

+ +

The health check-ins travel on the same channel as the audio, so if your sound reaches the other computer, your check-ins do too — one firewall rule covers both.

+ +

Network priority

+ +

RemSound automatically asks Windows to treat its audio as high-priority traffic, which helps most on a busy Wi-Fi network where other devices are streaming, downloading or video-calling. There's nothing to set up — it happens on its own every time RemSound starts. This helps on your local network and your home Wi-Fi; it makes no difference once the traffic leaves your home, but it does no harm either.

+ +

LAN — same Wi-Fi or Ethernet

+

On a normal home network, finding peers and checking their health both work with no setup. Start RemSound on two computers and they'll see each other within a second or two. You usually don't need to change any firewall settings.

+ +

WAN — computers in different places

+

Connecting two computers directly across the internet needs one of these:

+ + +

Automatic router port opening (UPnP)

+ +

Most home routers support a feature called UPnP (or its newer cousins NAT-PMP and PCP) which lets an app politely ask the router to open a port so the outside world can reach it. RemSound can use this so two computers can find each other across the internet without you having to log into the router and set up port forwarding by hand.

+ +

How to turn it on. Open Options → Preferences (Ctrl+P) and tick Automatically open my router for incoming connections (UPnP) (Alt+O). Off by default — we don't want to poke your router without permission. As soon as you tick the box, a status line appears just below it telling you what happened:

+ + + + + + + + +
Status line says…What it meansWhat to do
“Searching for a router that supports UPnP / NAT-PMP / PCP…”RemSound is asking around on your network for a router that speaks one of these languages. Usually finishes within a few seconds.Wait a moment.
“Router port opened. Peers can reach you at X.X.X.X:47830.”Your router has agreed to forward incoming audio to this computer. Tell the peer at the other end that address and they can connect using Add peer by IP.Pass that address (the part before the colon) to whoever you want to connect to.
“No router with UPnP / NAT-PMP / PCP found.”Either your router doesn't support it, the feature is turned off in the router's settings, or something on your network is blocking it.Try turning UPnP on in your router's settings page (look for “UPnP” or “NAT-PMP”), or use Tailscale instead.
“The router opened the port, but the external address is on a carrier-grade NAT.”Your router did its part, but your internet provider has put you behind a second layer of NAT (a sort of giant shared router) and there's nothing your home router can do about that. This is common on mobile broadband and on some cable connections.Use Tailscale or the relay server instead — both work fine through carrier-grade NAT.
“The router rejected the port-mapping request.”The router found the request but said no — usually because another device on your network already has the same port forwarded, or because the router has UPnP set to a restrictive mode.Check your router's UPnP settings, or fall back to manual port forwarding or Tailscale.
+ +

Across sleep and reboots. If your computer goes to sleep, RemSound asks the router to reopen the port automatically when it wakes up — some routers drop their port-forwarding list during long idle periods. Closing RemSound politely tells the router to forget the forwarding rule, so the port doesn't stay open after you're done.

+ +

Why this is off by default. Some networks — corporate offices, shared accommodation, hotel Wi-Fi — really don't want apps asking the router to open ports for them, either because there's a security policy or because the router is locked down. Off by default means RemSound never touches your router unless you explicitly tick the box.

+ +

Finding peers on Tailscale and other VPNs

+

The ordinary “I'm here” announcements that work on a home network don't travel across a VPN. RemSound works around this by also sending announcements directly to every address in your Remembered peers list. So:

+
    +
  1. One time only: each side adds the other's Tailscale address to its Remembered peers list (using the “Add peer by IP” button).
  2. +
  3. From then on, RemSound sends announcements straight to those addresses every 1.5 seconds.
  4. +
  5. The other side hears the announcement, adds the sender to its own list, and announces back.
  6. +
  7. Within seconds, both sides see each other in Discovered peers, with no further typing.
  8. +
+ +

So the rule is: only one side has to type the other's address once. After that, the discovery works both ways on its own.

+ +

Round-trip time and what it means

+

Round-trip time is how long it takes for sound to travel to the other computer and back.

+ + + + + + + +
Round-trip timeWhat you'll experience
0–2 msThe same computer talking to itself.
2–10 msSame local network. Effectively instant.
15–40 msTypical for Tailscale or modern broadband-to-broadband. Comfortable for conversation.
50–100 msTailscale 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.
+ +

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:

+ + + +

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

+ + + + + + +
WhereWhat it does
When you create a profileSaving 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 passwordsA 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

+ + + +

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:

+ + + +

Plus the codec choice (PCM, Opus broadcast quality, or Opus live latency), also on the Audio profile tab. Most people only need to pick a codec and a smoothness level and leave the rest at the default.

+ +

The sound-card cushion is automatic

+ +

Separately from the controls above — which manage the cushion against network jitter — RemSound also keeps a small cushion at the sound card itself, to smooth over the tiny timing differences between your two computers' sound clocks. From this version, RemSound sizes that cushion to each card automatically: a card that moves sound in bigger chunks (some onboard and USB cards do) gets a little more room, while a fast professional interface stays tight. You don't set this or think about it — it settles on the right amount for whatever card you're using.

+ +

Audio latency control

+ +

The Audio latency control tells the receiving side how much sound to keep in reserve as a cushion against uneven network timing. A bigger cushion means more delay but fewer clicks. A smaller cushion means less delay but more clicks when the network wobbles.

+ + + + + + + +
SettingBest forTrade-off
5–10 msLocal network, same computer.Crackles on any internet connection with even modest jitter.
20–40 msStable Tailscale or wired internet.A good balance — the added delay is usually inaudible.
50–80 msInternet with some Wi-Fi or jitter.Noticeable delay, but very robust against drop-outs.
100 ms+Bad networks; voice only.The delay is definitely noticeable.
+ +

Smaller is better when the network can handle it. If you'd rather not think about this number, turn on continuous auto-tune (below) and leave it.

+ +

Buffer smoothness

+ +

The Buffer smoothness list is a 1-to-10 scale for how patient the receiving side is when network jitter spikes. The default is 3.

+ + + + + + + +
SmoothnessBehaviourPick when
10 — smoothestThe receiving side tolerates the biggest jitter spikes without dropping any sound. Longest steady delay.Bad Wi-Fi, a busy internet connection, music sessions where any click is unacceptable.
4–7A middle ground. Smooths out most everyday internet jitter without much added delay.Most internet sessions over Tailscale or a direct connection.
3 — defaultModerate protection; brief clicks possible when jitter spikes.A stable internet connection or a quiet local network.
1 — tightest delayThe receiving side gives up immediately when sound is late. Frequent clicks, lowest delay.Testing on a local network, experiments where you want the lowest possible delay.
+ +

Smoothness and the Audio latency control work together — smoothness controls how the receiving side reacts when sound runs late; the latency value controls how big a head-start it builds up. A practical tip: if you can hear clicks, try raising smoothness by one or two before you reach for a bigger latency value.

+ +

Packet size — Standard or Small

+ +

Two choices: Standard (the default) and Small. This controls how much sound each network packet carries:

+ + + + + +
Packet sizeWhat changesPick when
StandardOne audio packet every 5 ms with PCM, every 20 ms with Opus broadcast quality, or every 2.5 ms with Opus live latency.Any internet or Tailscale connection — any time you don't have a guaranteed-clean local network.
Small (local network only)Halves how much sound each packet carries. Saves up to 2.5 ms of delay on the sending side.A same-house local network over wired Ethernet, where the network simply isn't going to drop packets or jitter.
+ +

The saving is small — at most a few milliseconds end to end. Small packets are useful when you and your collaborator are on the same local network and want to chase every last millisecond. For any internet connection it's a false economy, because doubling how many packets are sent also doubles the chance of running into jitter at the wrong moment, which you hear as clicks.

+ +

Lock to audio clock

+ +

The Lock to audio clock checkbox ties RemSound's sending timing to the sound device's own hardware clock, instead of letting Windows decide the pace. It's off by default. The label tells you what it does in your particular setup:

+ + + + + +
Your setupWhat “Lock to audio clock” does
No ASIO driver chosen (WASAPI only)The sender takes its timing from the WASAPI capture instead of from Windows' general timer. Tightens the sending delay.
An ASIO driver chosen (WASAPI and ASIO both running)Both paths tighten independently. Brief clicks are possible on either path if the connection can't keep up.
+ +
+Why you'd use it: Windows' general timer can wake the audio loop with up to about 6 ms of wobble, even at top priority. At target latencies under about 15 ms, that wobble shows up as clicks. Locking to the audio clock takes Windows' timer out of the picture — the sound device itself drives the timing. +
+ +

Continuous auto-tune

+ +

The Continuous auto-tune latency checkbox hands the latency value over to RemSound itself. When it's on, RemSound watches how evenly packets are arriving, every few seconds, and nudges the latency target up if it's seeing late packets, or down if the network has been calm. It deliberately ignores a single one-off stall — the kind a driver or Windows hiccup causes once and never again — and only raises the cushion when late audio keeps arriving, so one brief blip doesn't balloon your latency for the rest of the session. The companion Auto-tune latency interval (Alt+I) combo box sets how often it re-checks — 3, 5, 10, 15, or 30 seconds. Faster values react quickly to a change in the network but can feel a bit twitchy. Think of continuous auto-tune as a hands-off way to keep the cushion the right size as your network changes through the session.

+ +

If you turn auto-tune off, the latency value just stays wherever it last was.

+ +

Artefact sound type

+ +

When the playback reserve briefly runs empty, RemSound has to fill the gap with something. The Artefact sound type list decides what that gap sounds like:

+ + + +

Opus repairs lost sound automatically (built in, no setting)

+ +

Both Opus modes can automatically repair lost audio: each packet quietly carries a small backup copy of the previous packet's sound, so the receiving side can rebuild any single packet that goes missing on the way. The result is that a single missing packet becomes inaudible — no click, no glitch — instead of the small pop you'd otherwise hear. Two missing packets in a row still produce one click; that's just a limit of how Opus works, not something you can change.

+ +

This happens on its own — there's no switch for it. PCM mode doesn't have it.

+ +

Codec choice

+

Remember, the codec is the method RemSound uses to package the sound before sending it. There are three choices:

+ + + + + +
CodecQualityNetwork useDelay added by the codec
PCM 48k 24-bit — uncompressedBest, no loss at allAbout 2.3 MbpsNone — the sound goes out exactly as it was captured.
Opus, broadcast quality — loss tolerantVery goodAbout 200 kbpsAbout 12 ms.
Opus, live latency — for jamming and monitoringVery goodAbout 320 kbpsAbout 5 ms.
+ +

The difference between the two Opus choices is what they trade for what. Broadcast quality packs sound into larger chunks — bigger packets, sent less often, more tolerant of a wobbly connection. Live latency packs sound into very small chunks and sends them eight times more often, getting your audio there with almost no codec delay at all — close to PCM — at the cost of being a bit more sensitive to a noisy connection. Broadcast quality is the right pick for anything across the open internet; live latency is for playing along together over a clean local network or a wired connection.

+ +

PCM gives the very best sound with no quality loss at all, but it uses about ten times the network bandwidth of Opus. Over the open internet, Opus is almost always the right choice.

+ +

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.

+ +

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.

+ +

Connectivity tab

+ + + + + + + +
KeyAction
Alt+CFocus the Connected peers list
Alt+DFocus the Discovered peers list
Alt+RFocus the Remembered peers list
Alt+AAdd peer by IP
Alt+SFocus the Connection status read-out
+ +

(The logging controls — Enable logs and Write logs now — are in the Preferences dialog; reach it via Options → Preferences or Ctrl+P, then use Alt+L / Alt+W within the dialog.)

+ +

Audio inputs and outputs tab

+ + + + + + + + + + + +
KeyAction
Alt+DFocus the ASIO driver list (hidden if no ASIO drivers are installed)
Alt+RToggle Receive audio
Alt+1Focus ASIO outputs for received sound
Alt+2Focus ASIO inputs to send
Alt+3Focus WASAPI outputs for received sound
Alt+4Focus WASAPI outputs to send
Alt+5Focus WASAPI inputs to send
Alt+VFocus the volume slider
Alt+SToggle Send my audio
+ +

Audio profile tab

+ +

Some of these shortcuts shift depending on whether an ASIO driver is chosen. When one is chosen, the ASIO-path controls take the simpler Alt+L / Alt+T shortcuts, and the WASAPI-path controls move to Alt+W / Alt+Y so they don't collide.

+ + + + + + + + + + + + + + +
KeyAction
Alt+UToggle Use CPU and Windows performance settings in high priority mode (for this profile)
Alt+CFocus Audio codec
Alt+PFocus Packet size
Alt+DToggle Lock to audio clock
Alt+LFocus the latency control — the ASIO path when an ASIO driver is chosen, otherwise the single Audio latency control
Alt+TToggle continuous auto-tune — the ASIO path when an ASIO driver is chosen, otherwise the single Continuous auto-tune toggle
Alt+W(Only when an ASIO driver is chosen.) Focus the WASAPI-path latency control
Alt+Y(Only when an ASIO driver is chosen.) Toggle the WASAPI-path continuous auto-tune
Alt+IFocus the Auto-tune latency interval combo box. It drives the timing for the WASAPI auto-tune and, when an ASIO driver is chosen, the ASIO auto-tune too — one combo, both paths. Each path still settles at whatever latency its own calculation chooses; only the timing of the re-checks is shared. The label changes from “Auto-tune latency interval” to “Auto-tune interval — WASAPI and ASIO” once an ASIO driver is in use.
Alt+BFocus Buffer smoothness
Alt+AFocus Artefact sound type
+ +

File menu shortcuts (work from any tab)

+ + + + + + + + + + + + + + + + + + + +
KeyAction
Ctrl+SSave the current profile (or Save as if on a new profile)
Ctrl+KOpen the Keyboard shortcuts dialog
Ctrl+POpen the Preferences dialog
Ctrl+RStart or stop recording (toggles)
Alt+K, RStart or stop recording (via the menu — the Record menu is Alt+K, the item is R for “recording”)
Alt+K, OOpen the current recordings folder
Alt+K, CChange the recordings folder
Alt+F, OOpen profile
Alt+F, RRecent profiles (submenu — then 1..5 for the matching slot)
Alt+F, ASave profile as
Alt+F, MRename the current profile
Alt+F, NMinimise to tray
Alt+O, SRecording settings
Alt+O, KKeyboard shortcuts
Alt+O, TStartup behaviour
Alt+O, PPreferences
Alt+F, XExit
+ +

Always available

+ + + + + + + + +
KeyAction
F1Open this manual in your default web browser. Works anywhere in RemSound — the main window, every dialog, and the profile picker on first launch.
Ctrl+Tab / Ctrl+Shift+TabMove to the next / previous tab
Tab / Shift+TabMove between controls within the current tab
SpacebarTick or untick an item in any device list, or toggle the focused checkbox
Up / DownMove between items in any list
Alt+F4Close (the standard Windows shortcut)
+ +

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:

+ + + + + + + + + + + + + + + +
HotkeyActionDefault
Receive muteMute / unmute incoming sound (this computer)Ctrl+Shift+Alt+R
Send muteMute / unmute outgoing sound (this computer)Ctrl+Shift+Alt+S
Tray toggleShow / hide the main windowCtrl+Shift+F10
Quick profile switchPop up a list of all your profiles and switch to one — works from anywhere, even with RemSound in the tray (where it stays after the switch). See Quick profile switch below.Unset
Volume up / downAdjust this computer's received-sound volumeUnset
Start / Stop recordingStart or stop a recording on this computer. The same toggle as the Record menu's start/stop item and the in-app Ctrl+R, but it works system-wide (RemSound doesn't need to be the active window). See Recording to a file for what gets captured.Unset
Send remote volume up to peersTell every connected peer to raise their RemSound volume slider by 5 points (only obeyed by peers that have ticked “Accept remote volume commands”). It doesn't change your own volume. See Remote control.Unset
Send remote volume down to peersThe same, but lowering.Unset
Send remote receive mute toggle to peersTell every connected peer to toggle their RemSound receive mute.Unset
Send Windows global volume up to peersTell every connected peer to nudge their Windows volume up by one step (about 2%, the same as their keyboard volume key). This affects every app on the receiving computer, not just RemSound. Hold the hotkey down for bigger jumps. See Remote control.Unset
Send Windows global volume down to peersThe same, but lowering.Unset
Send Windows global mute toggle to peersTell every connected peer to toggle their Windows mute.Unset
+ +

You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.

+ +

Quick profile switch

+ +

Once you've given Quick profile switch a key, pressing it anywhere pops up a small list of every profile you have. Arrow to the one you want and press Enter (or click it) to switch straight to it. The profile you're currently on is marked in the list. A sound plays as the list opens, and the profile-switch sound plays the moment you pick one. Press Escape to close the list without switching.

+ +

If RemSound was minimised to the system tray when you pressed the hotkey, it switches the profile and stays in the tray — the window doesn't jump up in front of whatever you're doing. So you can change profiles mid-task without losing your place.

+ +

Your screen reader reads out the hotkeys

+ +

Once a hotkey is set, your screen reader reads it out whenever you land on the menu item or control it's tied to — for example, moving onto File → Open profile announces “Ctrl+O”, and a control with a global hotkey announces “press [your key] anywhere”. So you can learn and confirm your shortcuts just by arrowing around the window, without coming back to this dialog.

+ +

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.

+ +

RemSound's remote control feature gives you a way around this: you set up a hotkey on the desktop (the computer your keyboard is talking to) that sends a command across the audio link, telling the laptop's RemSound to raise, lower or mute its own listening volume. You stay in NVDA Remote, and the laptop responds.

+ +

Two kinds of remote command

+ +

There are two independent sets of remote-control hotkeys, both governed by the same opt-in toggle on the receiving end. Pick whichever fits the situation, or set up both:

+ + + + + +
SetWhat the receiving computer doesBest for
RemSound app volumeAdjusts the receiving peer's RemSound volume slider by 5 points per press, or toggles RemSound's receive mute. Only RemSound's sound is affected.Fine adjustments while RemSound's slider still has room to move. Doesn't touch the screen reader's volume or any other app.
Windows global volumeNudges the receiving peer's Windows master volume up or down by one step (about 2%, exactly the same as pressing the keyboard volume key there), or toggles the master mute. This affects every app on the receiving computer, including the screen reader.Real-world “I need this louder” situations, especially with hearing impairment, or when RemSound's slider is already at the top. Hold the hotkey down to ramp up over a longer range.
+ +

Both sets target the receiving computer. Neither one changes anything on the sending computer.

+ +

How to set it up

+ +
    +
  1. On the computer that should respond to remote commands (the one you're listening on — the laptop in the example): open Preferences (Ctrl+P) and tick Accept remote volume commands from peers. Save the profile (Ctrl+S) so the choice sticks. (One toggle covers both kinds of remote command.)
  2. +
  3. On the computer that should send remote commands (the one your keyboard is driving — the desktop in the example): open the Keyboard shortcuts dialog (Ctrl+K). Set whichever of the six remote-control rows you want: + +Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift+Down for one set, and Ctrl+Alt+Up / Ctrl+Alt+Down for the other). These are global hotkeys: they work as long as RemSound is running, no matter which app is in front.
  4. +
  5. That's it. Press the hotkey on the desktop — the laptop responds just as it would if you'd pressed the matching key on the laptop directly, and you hear the change without leaving the NVDA Remote session. Hold the Windows-volume hotkey down for a steady ramp, since Windows' key repeat fires the step over and over.
  6. +
+ +
+A heads-up about “Windows global volume”: the Windows volume affects everything on the receiving computer — not just RemSound. NVDA's voice gets louder with it, browser sound gets louder, every notification gets louder. For a hearing-impaired listener that's usually exactly what you want (everything gets to a usable level), but it's a very different thing from the in-app slider, which only changes RemSound's sound. Pick the right one for the situation. +
+ +

It works both ways

+ +

The feature is symmetric: both computers can both send and accept. If you set up hotkeys on both ends and tick “Accept remote volume commands” on both ends, either side can adjust the other's volume. There's no fixed “controller” and “controlled” computer.

+ +

What it does not touch

+ + + +
+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. +
+ +

17. Startup behaviour

+ +

Startup behaviour now lives on the Startup behaviour tab of the Preferences dialog (File → Preferences, or Ctrl+P). It has three independent toggles, plus a profile picker that appears when the third one is on. Each tick is saved straight away — there's no OK or Apply button. (It used to be a separate item on the Options menu; it moved into Preferences in the 2026 cue overhaul.)

+ + + + + + +
ToggleWhat it does
Start minimised to tray (Alt+M)RemSound hides itself in the system tray as soon as the main window finishes loading. The window is still reachable from the tray icon and the tray hotkey. Useful together with the auto-start option below, for a fully hands-off “turn the computer on, start streaming” setup.
Start RemSound automatically when this user logs in (Alt+A)Adds RemSound to (or removes it from) Windows' standard list of programs that start when you log in. After ticking it, Windows launches RemSound the next time you log in. It also appears under Task Manager → Startup, where you can disable it too. It applies to your account only — it doesn't need admin rights and doesn't affect anyone else who uses the same computer.
Start with a specific profile (Alt+P)When ticked, RemSound skips the startup profile picker and loads the profile you choose in the list below. When unticked, the profile picker shows as normal. If you don't have any saved profiles yet, ticking this shows a one-time warning and stays unticked — save a profile first, then come back. To bring the picker back temporarily without losing your choice, untick the box, start RemSound normally, then tick it again afterwards.
+ +

Profile to start with (Alt+L) — the list of your saved profiles. It only shows when the third toggle is on. Pick a profile and the choice is saved straight away.

+ +

Combining the three for a hands-off start

+ +
    +
  1. Save a profile with the device choices, peers, and sound settings you want for “always-on” use.
  2. +
  3. Go to the Startup behaviour tab in Preferences. Tick all three: Start minimised, Start automatically when this user logs in, and Start with a specific profile — then pick the profile you just saved.
  4. +
  5. Close the dialog. Reboot, or log out and back in, to test — RemSound starts itself, loads the profile, and goes straight to the tray. Sound starts flowing as soon as the peer is reachable.
  6. +
+ +
+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. +
+ +

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. Sixteen kinds of event have a cue:

+ + + + + + + + + + + + + + + + + +
CuePlays when
Connect soundA peer goes from “trying” or “unreachable” to actually connected.
Disconnect soundA previously-connected peer drops off (network blip, peer closed RemSound, computer went to sleep, etc).
Recording start soundYou start a recording.
Recording stop soundYou stop a recording.
Profile saved soundA profile is saved — whether via File → Save or File → Save as.
Profile switched soundYou switch to a different profile — from the Recent profiles menu, the Quick profile switch popup, or File → Open profile. It plays the moment you pick the new profile. It deliberately does not play on a fresh start into your first profile, so it isn't layered on top of the connect sound at launch.
Profile menu open soundThe Quick profile switch popup opens.
Update soundAn update is about to install — it plays just before RemSound closes to update itself. Handy when updates install silently in the background, so you're not caught off guard when RemSound restarts. Plays whether you ran the update by hand or it installed on its own.
Startup soundRemSound has finished starting up. It plays once at launch, even when RemSound opens straight to the notification area, so you know it's running.
Send turned on / off soundYou turn sending your audio on or off — whether by ticking the Send my audio box in the window or by pressing its mute shortcut. There's a separate sound for on and for off.
Receive turned on / off soundYou turn receiving audio on or off — from the Receive audio box or its mute shortcut. Again, a separate sound for on and for off.
Minimise (hide) soundRemSound's window minimises to the notification area (hides).
Restore (show) soundRemSound's window is brought back from the notification area (shows).
Checkbox ticked / unticked soundYou tick or untick any checkbox anywhere in RemSound — a click for ticked, a different one for unticked. This gives instant feedback on which way a box just went, which is especially handy in the busy inputs and outputs lists. There's a separate sound for ticking and for unticking.
+ +

All of these cues play through your default Windows sound output, which is separate from the audio RemSound is sending or receiving. They don't appear in a normal recording. (The exception: if your sending side is capturing the very output device the cues play through, then they get captured along with everything else from that device.)

+ +

The Audio cues tab

+ +

Open File → Preferences (or Ctrl+P) and go to the Audio cues tab. (Preferences is now organised into four tabs — General, Audio cues, Startup behaviour and Update settings — which you move between with Ctrl+Tab, or with the arrow keys when the row of tab names has focus.)

+ +

The Audio cue sounds (Alt+N) list shows every cue by name. Use the up and down arrow keys to move between them — as you land on each cue, RemSound plays its current sound, so you can hear what's set just by arrowing through.

+ +

Turning a cue on or off, and choosing its sound

+ +

Just below the cue list is a Choose sound (Alt+D) list, which controls whichever cue is highlighted above. Its first entry is (none); the rest are the built-in sounds that cue ships with. Arrow through it and RemSound plays each one as you land on it:

+ + + +

Every cue starts switched on, using its first sound. "(none)" is how you silence a cue — there are no separate tickboxes any more. Your choices are remembered for next time (per-event cues travel with the active profile; the app-level cues like startup and minimise are remembered for the whole installation).

+ +

The tick settings are saved with the active profile, so different profiles can have different combinations of cues on. For example, a “quiet listening” profile might have all cues off, while a “live monitoring” profile keeps them on. When you save the profile (Ctrl+S), the new settings travel with it.

+ +

Previewing and choosing a different sound

+ +

Below the list are two buttons that act on whichever cue is currently highlighted in the list:

+ + + +

Both buttons' labels update as you arrow through the list, so you always know which cue you're about to act on.

+ +

If a cue's sound file goes missing

+ +

If a cue is switched on but RemSound can't find its sound file — for example you delete a WAV you'd browsed for — RemSound brings its window to the front (even when it's minimised) and tells you which cue and which event it was, then switches that cue to (none) so it stops trying. Pick a sound for it again in the Choose sound list, or Browse for a new file, to turn it back on.

+ +

Keyboard clicks while typing

+ +

Just below the cue controls is a tickbox, Play keyboard clicks when typing into any edit field (Alt+K), which is on by default. With it ticked, typing into any text box anywhere in RemSound plays a soft click on each key, so you get an audible sense of your typing. It only sounds while your cursor is actually in an edit field — move out of the field and it stops.

+ +

Password boxes are treated specially: they play the same key click and a second, distinct sound at the same instant, so you can tell by ear when you're typing into a masked password field rather than an ordinary one. That second sound only happens in password fields.

+ +

Untick the box to switch all of this off. The setting applies to the whole installation.

+ +

Custom sound choices are saved with the active profile, the same way the tick states are. Different profiles can have completely different cue palettes — a “studio” profile might use one set of sounds, a “broadcast” profile another. The custom files themselves stay where you picked them on your disk; the profile just remembers their paths.

+ +

Going back to the default sound

+ +

To revert a cue to its default sound, right-click the Browse for [cue name]… button and pick Use default sound. The custom path is forgotten and the cue goes back to playing the default WAV that ships with RemSound. The right-click option is greyed out when the cue is already using its default. (Alternatively, click Browse and pick a file from RemSound's own sounds folder — inside user settings and logs — and RemSound treats that as “use default” and clears the override automatically.)

+ +

Where the cue sounds live

+ +

RemSound keeps the cue WAV files in a sounds folder inside user settings and logs — the same folder your settings and profiles live in. Each cue ships with a small set of numbered sound files there. They're named after the cue with a number on the end — for example connect 1.wav and connect 2.wav for the connect cue, record start 1.wav and record start 2.wav for the recording-start cue, and so on. The Choose default sound list described above simply picks between the numbered files a cue has. If you add more numbered files of your own following the same pattern, they show up in the list automatically — there's no fixed limit.

+ +

Because this folder is inside user settings and logs, RemSound updates never overwrite it. So if you drop your own WAV files in here in place of the defaults, your versions stay put when you update — you don't have to set them up again.

+ +

If a cue's WAV file is missing — either the default file doesn't exist or a custom path points at a file you've since deleted — the cue stays silent rather than producing an error. RemSound logs a note in the diagnostic log (if logging is on) so you can see what happened.

+ +
+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. +
+ +

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.

+ +

Settings in Preferences

+ +

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

+ + + + + + + + +
SettingShortcutWhat it does
Check for updates on startup (checkbox)Alt+SWhen ticked, RemSound has a quiet look for a newer version a few seconds after each launch. On by default. Combined with Silently install updates below, this means leaving RemSound to keep itself up to date without you ever needing to press anything. Untick if you'd rather only ever check on a timer or by pressing the manual button.
Then check every (drop-down)Alt+UHow 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+NChecks 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+IWhen 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+HWhen 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

+ +

If RemSound finds an update right after launch and is set to install silently, it now shows a small window so you're not surprised when the app closes a few seconds in. The window says “RemSound vX.X is ready to install” with three buttons:

+ + + +

A short countdown picks Install now automatically if you don't choose anything — long enough to read the version number, short enough that walking away from your desk doesn't block the silent update. Esc has the same effect as Postpone.

+ +

What happens during an install

+ +

RemSound can't replace its own program file while it's running, so it hands the job to a fresh copy of the new version, which does the swap once RemSound has closed:

+ +
    +
  1. RemSound downloads the new version into a temporary folder kept on your own machine, away from the install folder.
  2. +
  3. It starts the new copy from that temporary folder, and closes itself.
  4. +
  5. The new copy waits for RemSound to close fully, then moves your old program files aside and copies the new ones into place. If a file is briefly in use, it waits and retries rather than giving up.
  6. +
  7. It reopens RemSound on the same profile you were running, and clears the temporary folder away.
  8. +
+ +

You'll see the window close, then reopen on the new version within a second or two. Anything that was unsaved in the old session (a profile you were partway through editing, for example) is lost — RemSound will not save it for you before installing. Save first if you've been making changes.

+ +

The same profile picks up automatically after an update

+ +

When the install finishes and RemSound reopens, it loads the same profile that was running just before the update — you don't see the profile picker, and your devices, peer list, codec and latency settings all come back exactly as they were. This means a silent update in the middle of a session drops the audio briefly while the install finishes, then your session reconnects on its own. You don't have to be at the computer when it happens.

+ +

This is a one-shot, just-after-the-update behaviour. The very next time you launch RemSound manually (from the desktop, the Start menu, or the tray icon), it follows your normal startup choice — the picker if that's how you've set it, or your chosen startup profile if you've picked one in Options → Startup behaviour.

+ +

If the profile that was running can't be found after the update (you'd renamed or moved it during the session, for example), RemSound falls back to your normal startup behaviour rather than getting stuck.

+ +

If an install fails

+ +

The update download is best-effort: a flaky network, or a temporarily-unavailable version, will pop up a message saying it couldn't finish, and leave your running version untouched. The address of the download page is in that message, so you can get the new version in a browser and install it by hand if you need to. If you installed RemSound into Program Files without giving your account permission to write to that folder, the install can't replace the files there — either fix the permission or move RemSound to a folder you can write to (somewhere inside your own user folder, for instance).

+ +

If the file swap itself can't finish — for example because a sync app or another program was holding one of the files open and wouldn't let go — RemSound puts your previous version back exactly as it was and reopens it, rather than leaving you with a half-finished install. It writes a short note called update-failed.txt next to the program explaining what happened. Nothing is broken — just try Help → Check for updates again, and it almost always goes through on the next attempt. If a sync app like Dropbox is involved, closing RemSound and giving it half a minute to settle before retrying helps.

+ +

A step-by-step record of every update is kept in a file called updater.log in the install folder — useful if a failure keeps happening and you want to share it for diagnosis.

+ +

The About dialog and release notes

+ +

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.

+ +

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.

+ +

What gets recorded

+ +

Recording captures the sound at fully-mixed, fully-finished points: for the received side, after volume and mute have been applied (so the file matches what you hear); for the sent side, the raw captured sound just before it's packaged for sending (so the file is the same whatever codec you chose). The three source choices:

+ + + +

File formats

+ +

All four formats record at a 48 kHz sample rate, and every row in the attributes list states the rate clearly so it's never in doubt.

+ + + + + + + +
FormatWhat you getWhen to pick it
WAV (default)An uncompressed file. No quality loss, but large — about 17 MB per minute at 24-bit stereo. Bit-depth choices: 16-bit, 24-bit (the default), or 32-bit float (the highest quality). Plus stereo or mono.Keeping a master copy, editing in audio software, anything where you might want to re-master later.
MP3A compressed file at one of four bitrates: 128 / 192 / 256 / 320 kbps. Stereo or mono. MP3 plays just about everywhere.Long sessions where file size matters; quickly sending someone a listen-once file.
OGG-OpusA compressed file using Opus, at one of four target bitrates: 96 / 128 / 192 / 256 kbps. Stereo or mono. The file extension is .opus.Smaller files than MP3 at similar quality; plays in most modern players (VLC, mpv, web browsers).
FLACA compressed file with no quality loss at all. Bit-depth choices: 16-bit or 24-bit (the default). Stereo or mono. Files are typically about half the size of the same recording as WAV, with no loss of quality.Keeping a master copy when you also want a sensible file size — it plays back identically to WAV but is half the size.
+ +

Surviving a crash. All four formats are designed to leave a playable file behind even if RemSound crashes partway through a recording. You lose at most about 5 seconds of recently-captured sound on a crash, never the whole session.

+ +

Start and stop sound cues

+ +

RemSound plays a short ding when a recording starts and another when it stops, so you have an audible confirmation that the toggle actually took effect. These are two of the eight cues described in Audio cue sounds. You can turn either or both off, replace them with your own WAV files, and preview them from Preferences. The defaults live at sounds\record start.wav and sounds\record stop.wav inside the user settings and logs folder.

+ +

Where recordings go

+ +

By default, recordings live in <RemSound install folder>\recordings\<computer name>\. Each recording creates a new file with a name like RemSound-2026-05-19_14-30-00, so files never overwrite each other.

+ +

You can change the folder via Record → Change recordings folder. Choosing a different folder saves that location into your current profile, so it travels with the rest of your settings — switching profiles can switch your recording destination too. If a saved profile points at a folder that doesn't exist on the computer loading it, the recorder quietly falls back to the default location for that computer.

+ +

Record → Open current recordings folder opens Windows File Explorer on whatever folder is currently set, creating it on the spot if no recording has been made there yet.

+ +

Starting and stopping

+ +

There are four ways to start or stop a recording:

+ + +

Recording happens in the background, so it doesn't affect the sound or the network. If your disk ever can't keep up, the recorder drops the oldest queued sound (never the newest) and notes it in the log; in practice you'll only see that on a fully-saturated USB stick or a very slow network drive.

+ +

Recording settings dialog

+ +

Reached via Record → Recording settings. Up to five keyboard-navigable lists, laid out left to right. FLAC's compression level has its own list, but it only shows when the file format is FLAC.

+ + + + + + + + +
ListShortcutWhat goes in it
Recording sourceAlt+SReceive only / Send only / Both. See the source explanation above.
File formatAlt+FWAV / MP3 / Ogg-Opus / FLAC. The attributes list (and FLAC compression list) to the right change whenever you change this.
Audio format attributesAlt+AChanges to match the format, with the 48 kHz sample rate stated on every row so there's no ambiguity. WAV: three rows (16-bit / 24-bit / 32-bit float). MP3: four rows (128 / 192 / 256 / 320 kbps). OGG-Opus: four rows (96 / 128 / 192 / 256 kbps). FLAC: two rows (16-bit / 24-bit).
FLAC compression levelAlt+LShown only when FLAC is the chosen file format. Nine rows for levels 0 to 8, with friendly labels on the ends (“0 — fastest, biggest file”, “5 — default”, “8 — slowest, smallest file”). Every level produces an identical, no-loss recording — it's purely a trade-off between encoding speed and file size.
ChannelsAlt+CStereo or Mono. Applies to every format.
+ +

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.

+ +

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 inside user settings and logs — the same folder your settings and profiles live in. One file per launch.

+ +

The file contains two kinds of rows:

+ + + + + +
KindContents
EVTEvent lines — startup, a peer being selected, capture starting, errors, and so on.
SNAPOne-second snapshots of running figures: codec, latency target, how much sound is buffered, packets sent, packets received, drop-outs, drops, and peer round-trip times.
+ +

The Write logs now button in the Preferences dialog (Alt+W within that dialog) writes a “user requested write logs now” marker into the log, so you can find that moment in the file afterwards.

+ +

Logs are plain text and can be opened in any text editor, or in a spreadsheet. The most useful figures when something feels wrong:

+ + +

22. Command-line options

+ +

RemSound is normally a windowed program you click to open. But it can also take command-line options — short text instructions you type after the program name. They are handy for three things: checking a machine quickly (what devices are present, does the audio path work at all), getting a support report to send to whoever helps you, and starting RemSound a particular way from a shortcut or a script.

+ +

To use them, open a command prompt (press the Windows key, type cmd, press Enter), then run RemSound with the option after it. If RemSound is on your desktop you can type the whole path in quotes, for example:

+ +
+"C:\Users\you\Desktop\RemSound\RemSound.exe" --devices
+
+ +

The options that just report something print their answer straight into the same command window as plain text — a screen reader reads it normally — and then RemSound exits without opening a window. The start-up options open RemSound as usual, just set up the way you asked.

+ +

Options that print something and then exit

+ + + + + + + + +
OptionWhat it does
--help or -hLists every option, the same as this section in short form.
--versionPrints which version of RemSound is installed, for example “RemSound 3.9”.
--devicesLists every microphone and line-in, every speaker and headphone output, and every ASIO driver on the machine — each with its sample rate, channel count and the exact device id RemSound uses internally. This is the quickest way to confirm an interface is actually present and seen by Windows.
--selftest
(or --smoke-test)
Runs RemSound's built-in self-test and reports PASS or FAIL. It works through a list of named checks: a full audio round-trip on the machine on its own (capture → encode → send across the network layer to itself → receive → decode, for both quality settings), the audio encryption, the network packet format, saving and reloading settings and a profile, that a diagnostics report never leaks a password, and that the bundled sounds and manual are present. No sound is played out, so it is safe to run silently. Add --seconds N to make the audio part run for longer than the default.
--perftest
[--seconds N]
Runs the audio path through several short cycles and reports whether RemSound's handle, memory and thread use stays bounded — a quick check for resource leaks. Prints the numbers each cycle so two builds can be compared. No sound is played out.
--diagnosticsWrites a single plain-text report file holding the version, the operating system, the current settings, the list of profiles, the full device list, a check of the Windows microphone-privacy permission, a quick live audio self-check, the most recent session snapshot and recent warnings from the log, and the tail of the most recent log. With no path it saves into the user settings and logs folder and prints where it put it; you can also give a path, for example --diagnostics C:\Users\you\Desktop\report.txt. This is the file to send when asking for help — it answers most questions in one go.
+ +

Options that change a setting or control a running copy, then exit

+ + + + +
OptionWhat it does
--log on or --log offTurns the diagnostic log on or off. The change takes effect the next time RemSound starts. The same setting lives in the Preferences dialog; this is just a way to set it without opening the window.
--closeCloses a copy of RemSound that is already running. Useful in a script that needs to restart it.
+ +

Options that change how RemSound starts

+

These open RemSound as normal, set up the way you ask, and are meant for shortcuts and scripts.

+ + + + + + +
OptionWhat it does
--profile "<name>"Starts straight into the named profile and skips the profile picker. Put the name in quotes if it contains a space, for example --profile "Studio link".
--connect <ip>Starts and connects to a peer at that address. You can give just an address (--connect 192.168.1.42) or an address and port (--connect 192.168.1.42:47830); with no port it uses RemSound's normal port, 47830. If you don't also give a --profile, it starts on a fresh blank profile already pointed at that peer.
--minimized or --trayStarts minimized to the notification area, with no window popping up. Pair it with --profile or --connect so it has something to do without waiting at the picker.
--config-dir <folder>Uses an explicit folder for this run's settings, profiles, logs and sounds, instead of the usual location. It lets you (or an automated test) run RemSound against a throwaway folder without touching your real settings. Works with any command — for example --selftest --config-dir C:\Temp\rstest or --diagnostics --config-dir C:\Temp\rstest.
+ +

Examples

+
+RemSound.exe --devices
+RemSound.exe --selftest --opus
+RemSound.exe --diagnostics
+RemSound.exe --profile "Studio" --minimized
+RemSound.exe --connect 192.168.1.42
+
+ +

A common support sequence: ask the person to run --diagnostics and send you the file, then have them run --selftest — if that says PASS, capture, encoding and the audio path are all sound on their machine and the problem is somewhere in the connection between you.

+ +

23. Troubleshooting

+ +

I don't hear my friend

+
    +
  1. Connectivity tab: is your friend in Connected peers with a healthy round-trip time (for example “192.168.1.5: 27 ms”), not unreachable or pending?
  2. +
  3. Audio inputs and outputs tab: is Receive audio ticked?
  4. +
  5. Same tab: is at least one output device ticked, in the WASAPI or the ASIO output list?
  6. +
  7. Same tab: is the volume slider above zero?
  8. +
+ +

My friend doesn't hear me

+
    +
  1. Audio inputs and outputs tab: is Send my audio ticked?
  2. +
  3. Same tab: is at least one capture source ticked across the three send lists?
  4. +
  5. If you're using a microphone: is Windows allowing apps to use it? RemSound now pops up a warning when you switch on a microphone Windows is blocking — including when a profile loads with one already on — but to check by hand, open Settings → Privacy & security → Microphone and make sure both Microphone access and Let desktop apps access your microphone are on. (When Windows blocks it the mic sends silence rather than failing, so it's easy to miss.)
  6. +
  7. Have they ticked your name in their Discovered peers list?
  8. +
+ +

I can hear them but the sound crackles

+ + +

The sound is fine but the delay feels long

+ + +

The ASIO sound is grainy or constantly micro-clicks

+

Most likely your ASIO latency target is below the network's real-world jitter level. The receiving side fights to hold the reserve at the target, and that fight is audible. Raise ASIO latency in milliseconds (Alt+L) on the Audio profile tab to 25 ms or more and the graininess should disappear. Even pure-ASIO setups can't safely sustain a receive reserve below about 15 ms over real networks; aim higher on Wi-Fi.

+ +

I can't see my friend in Discovered peers

+ + +

A peer rebooted or changed address and the sound didn't come back

+

RemSound follows a peer to its new address on its own. If the address you connected to stops responding but the same peer is still reaching you from a different address on your network — because they rebooted onto a new address, for example — RemSound re-points to the live address within a few seconds and the sound resumes without you doing anything. If it doesn't recover, the peer is genuinely unreachable (off, asleep, or a firewall is blocking the new path).

+ +

One side says “unreachable” even though sound is flowing

+

The health check-ins use the same channel as the audio, so if the sound gets through, the check-ins should too. If one side shows “unreachable” while the sound plays fine, make sure both computers are running the same version of RemSound — an older version on either end can speak a slightly different check-in language.

+ +

My other audio went silent or crackly when I selected an ASIO driver

+

You probably picked Realtek ASIO. It's a generic driver, not tied to Realtek hardware, and it tends to grab whatever Windows treats as the default sound device — usually the same one your screen reader is using. Set the ASIO driver picker back to (none), or pick a different ASIO driver.

+ +

The device list shows old devices that are no longer plugged in

+

RemSound reacts the moment a device is plugged in or unplugged, so an unplugged device should disappear within a second or two. If one lingers, restart RemSound — Windows' own device list occasionally needs a nudge.

+ +

No sound after the computer wakes from sleep

+

RemSound notices when the computer has just woken up, waits a moment for any USB sound devices to come back to life, and rebuilds its audio engine from scratch — you'll briefly see a small “Reconnecting to audio driver” window during the rebuild, then sound should resume on its own. If sound still doesn't come back, click on the ASIO driver picker on the Audio inputs and outputs tab and re-pick the same driver (or pick (none) and then re-pick your driver). That triggers the same full rebuild manually. As a last resort, quit and reopen RemSound.

+ +

A sound card you were listening through was unplugged

+

If a sound card you're playing received audio through is unplugged and then plugged back in, RemSound now re-opens it on its own and the sound resumes — you don't have to re-tick it in the output list. This works when the card comes back as the same Windows device, which is the usual case when you plug it into the same socket. If you move it to a different USB socket and Windows treats it as a brand-new device, just tick it again in the output list.

+ +

UPnP says “no router found” even though my router supports it

+

The most common reasons:

+ +

If none of those apply, just fall back to Tailscale — it works without involving the router at all.

+ +

24. Glossary

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
TermMeaning
WASAPIThe normal Windows way of handling sound. Every speaker and microphone in your Windows sound settings works this way. The delay it adds is around 10–30 ms.
ASIOA faster, more direct way of handling sound, used by professional audio equipment. It talks straight to the hardware, giving a delay of under 5 ms. It only works if your audio interface came with an ASIO driver.
Loopback captureCapturing what's currently being played out of an output device, rather than what's coming in from a microphone. The “WASAPI outputs to send” list does loopback capture.
Channel pairA stereo pair of channels on an ASIO driver. Pair 1 is channels 1 and 2, Pair 2 is channels 3 and 4, and so on.
CodecThe method RemSound uses to package the sound before sending it. RemSound offers PCM (no compression) and Opus (compressed).
OpusA high-quality codec that compresses sound to use much less network bandwidth, and can repair single lost packets on its own. The right choice for internet connections.
PCMAn uncompressed codec — the very best quality with no loss at all, but it uses far more network bandwidth than Opus. Best on a local network or a fast connection.
FLACA file format for recordings that compresses the sound with no loss of quality — the file plays back identically to an uncompressed WAV, but is about half the size.
LatencyThe small delay between sound leaving one computer and arriving at the other.
JitterWhen network packets arrive unevenly instead of in a steady stream. This is why a reserve of sound is kept on the receiving side.
PeerAnother computer running RemSound that you're connected to or want to connect to.
HeartbeatA small message that connected computers exchange every second to confirm they can still reach each other and to measure the round-trip time. It travels on the audio channel (47830 by default).
DiscoveryThe way RemSound computers find each other on the network without you having to know each other's addresses up front.
TailscaleAn easy-to-use VPN that puts your computers on a private network together. The simplest way to connect RemSound across the internet without changing your router settings.
UPnPShort for “Universal Plug and Play”. A feature most home routers support that lets an app politely ask the router to open a port for incoming connections, without the user having to log into the router. RemSound uses UPnP (and its newer relatives NAT-PMP and PCP) to set up port forwarding automatically when you tick “Automatically open my router for incoming connections” in Preferences. Off by default.
NATShort for “Network Address Translation”. The way your router lets several computers share a single internet connection — one public address on the outside, lots of private addresses on the inside. Most home networks use NAT, which is why you usually need port forwarding (or UPnP, or a VPN) for two computers in different places to reach each other directly.
Carrier-grade NATAn extra layer of NAT that some internet providers (especially on mobile broadband and some cable connections) put in between your router and the rest of the internet. Your home router opens a port fine, but the provider's NAT in front of it still blocks incoming connections. RemSound's UPnP status line warns you when this is the case — the way through it is a VPN like Tailscale, or the relay server.
Auto-tuneRemSound automatically adjusting the latency target based on how evenly packets are arriving. Off by default; turn it on with the Continuous auto-tune checkbox on the Audio profile tab.
ProfileA saved snapshot of every RemSound setting and choice — device ticks, send / receive states, codec, latency, peers, hotkeys, ASIO driver choice, the lot. Stored as one settings file. You pick one at startup, and can switch with File → Open profile.
New profileAn entry in the startup profile picker that begins a session with all the defaults — nothing ticked, no peers, no saved name. A clean starting point for a new profile, or for a one-off session you don't plan to save.
Lock to audio clockA sending-side timing mode that takes its timing straight from the sound device's hardware clock instead of from Windows. Removes a few milliseconds of wobble at tight latency targets. Off by default. Set with the checkbox of the same name on the Audio profile tab.
ConcealmentA receiving-side feature that fills brief gaps in the playback reserve with a small noise burst (the default) or an obvious click. You choose which on the Audio profile tab, in the Artefact sound type list. Opus also has its own repair of lost packets on top of this.
Remote controlA RemSound feature that lets one connected peer adjust another peer's listening volume (or toggle their receive mute) using global hotkeys. There are two sets of commands: one adjusts the receiver's RemSound volume slider, the other adjusts the receiver's Windows volume. Off by default on both ends; the receiver opts in via “Accept remote volume commands from peers” in the Preferences dialog (Ctrl+P), and the sender sets up hotkeys in the Keyboard shortcuts dialog (Ctrl+K). Designed for the “I'm NVDA-Remote'd into my desktop and want to nudge the laptop's volume” case. See section 15.
+ +
+ + + diff --git a/_deploy/runtimes/linux-arm64/native/libopus.so b/_deploy/runtimes/linux-arm64/native/libopus.so new file mode 100644 index 0000000..ba056f0 Binary files /dev/null and b/_deploy/runtimes/linux-arm64/native/libopus.so differ diff --git a/_deploy/runtimes/linux-armv6/native/libopus.so b/_deploy/runtimes/linux-armv6/native/libopus.so new file mode 100644 index 0000000..ff7ce69 Binary files /dev/null and b/_deploy/runtimes/linux-armv6/native/libopus.so differ diff --git a/_deploy/runtimes/linux-x64/native/libopus.so b/_deploy/runtimes/linux-x64/native/libopus.so new file mode 100644 index 0000000..73c5fc5 Binary files /dev/null and b/_deploy/runtimes/linux-x64/native/libopus.so differ diff --git a/_deploy/runtimes/osx-arm64/native/libopus.dylib b/_deploy/runtimes/osx-arm64/native/libopus.dylib new file mode 100644 index 0000000..910858c Binary files /dev/null and b/_deploy/runtimes/osx-arm64/native/libopus.dylib differ diff --git a/_deploy/runtimes/osx-x64/native/libopus.dylib b/_deploy/runtimes/osx-x64/native/libopus.dylib new file mode 100644 index 0000000..fa28536 Binary files /dev/null and b/_deploy/runtimes/osx-x64/native/libopus.dylib differ diff --git a/_deploy/runtimes/win-arm64/native/opus.dll b/_deploy/runtimes/win-arm64/native/opus.dll new file mode 100644 index 0000000..e1e9617 Binary files /dev/null and b/_deploy/runtimes/win-arm64/native/opus.dll differ diff --git a/_deploy/runtimes/win-x64/native/opus.dll b/_deploy/runtimes/win-x64/native/opus.dll new file mode 100644 index 0000000..bf20cb0 Binary files /dev/null and b/_deploy/runtimes/win-x64/native/opus.dll differ diff --git a/_deploy/runtimes/win-x86/native/opus.dll b/_deploy/runtimes/win-x86/native/opus.dll new file mode 100644 index 0000000..60a669f Binary files /dev/null and b/_deploy/runtimes/win-x86/native/opus.dll differ diff --git a/_deploy/sounds/Profile menu open 1.wav b/_deploy/sounds/Profile menu open 1.wav new file mode 100644 index 0000000..7b62282 Binary files /dev/null and b/_deploy/sounds/Profile menu open 1.wav differ diff --git a/_deploy/sounds/check 1.wav b/_deploy/sounds/check 1.wav new file mode 100644 index 0000000..1210e19 Binary files /dev/null and b/_deploy/sounds/check 1.wav differ diff --git a/_deploy/sounds/check 2.wav b/_deploy/sounds/check 2.wav new file mode 100644 index 0000000..fa2e53b Binary files /dev/null and b/_deploy/sounds/check 2.wav differ diff --git a/_deploy/sounds/connect 1.wav b/_deploy/sounds/connect 1.wav new file mode 100644 index 0000000..bfe55d3 Binary files /dev/null and b/_deploy/sounds/connect 1.wav differ diff --git a/_deploy/sounds/connect 2.wav b/_deploy/sounds/connect 2.wav new file mode 100644 index 0000000..f7e0187 Binary files /dev/null and b/_deploy/sounds/connect 2.wav differ diff --git a/_deploy/sounds/disconnect 1.wav b/_deploy/sounds/disconnect 1.wav new file mode 100644 index 0000000..df0d5e2 Binary files /dev/null and b/_deploy/sounds/disconnect 1.wav differ diff --git a/_deploy/sounds/disconnect 2.wav b/_deploy/sounds/disconnect 2.wav new file mode 100644 index 0000000..5e4c68b Binary files /dev/null and b/_deploy/sounds/disconnect 2.wav differ diff --git a/_deploy/sounds/key 1.wav b/_deploy/sounds/key 1.wav new file mode 100644 index 0000000..185e482 Binary files /dev/null and b/_deploy/sounds/key 1.wav differ diff --git a/_deploy/sounds/key 2.wav b/_deploy/sounds/key 2.wav new file mode 100644 index 0000000..75518b9 Binary files /dev/null and b/_deploy/sounds/key 2.wav differ diff --git a/_deploy/sounds/key 3.wav b/_deploy/sounds/key 3.wav new file mode 100644 index 0000000..dc27952 Binary files /dev/null and b/_deploy/sounds/key 3.wav differ diff --git a/_deploy/sounds/key 4.wav b/_deploy/sounds/key 4.wav new file mode 100644 index 0000000..5cd1fd2 Binary files /dev/null and b/_deploy/sounds/key 4.wav differ diff --git a/_deploy/sounds/maximise 1.wav b/_deploy/sounds/maximise 1.wav new file mode 100644 index 0000000..eeb5232 Binary files /dev/null and b/_deploy/sounds/maximise 1.wav differ diff --git a/_deploy/sounds/maximise 2.wav b/_deploy/sounds/maximise 2.wav new file mode 100644 index 0000000..8151356 Binary files /dev/null and b/_deploy/sounds/maximise 2.wav differ diff --git a/_deploy/sounds/minimise 1.wav b/_deploy/sounds/minimise 1.wav new file mode 100644 index 0000000..ea6bf1b Binary files /dev/null and b/_deploy/sounds/minimise 1.wav differ diff --git a/_deploy/sounds/minimise 2.wav b/_deploy/sounds/minimise 2.wav new file mode 100644 index 0000000..4250aca Binary files /dev/null and b/_deploy/sounds/minimise 2.wav differ diff --git a/_deploy/sounds/passkey.wav b/_deploy/sounds/passkey.wav new file mode 100644 index 0000000..4559a4e Binary files /dev/null and b/_deploy/sounds/passkey.wav differ diff --git a/_deploy/sounds/profile 1.wav b/_deploy/sounds/profile 1.wav new file mode 100644 index 0000000..dac1272 Binary files /dev/null and b/_deploy/sounds/profile 1.wav differ diff --git a/_deploy/sounds/profile 2.wav b/_deploy/sounds/profile 2.wav new file mode 100644 index 0000000..584ebbd Binary files /dev/null and b/_deploy/sounds/profile 2.wav differ diff --git a/_deploy/sounds/profile menu open 2.wav b/_deploy/sounds/profile menu open 2.wav new file mode 100644 index 0000000..b232a73 Binary files /dev/null and b/_deploy/sounds/profile menu open 2.wav differ diff --git a/_deploy/sounds/recieve off 1.wav b/_deploy/sounds/recieve off 1.wav new file mode 100644 index 0000000..759497c Binary files /dev/null and b/_deploy/sounds/recieve off 1.wav differ diff --git a/_deploy/sounds/recieve off 2.wav b/_deploy/sounds/recieve off 2.wav new file mode 100644 index 0000000..500e8ec Binary files /dev/null and b/_deploy/sounds/recieve off 2.wav differ diff --git a/_deploy/sounds/recieve on 1.wav b/_deploy/sounds/recieve on 1.wav new file mode 100644 index 0000000..25e270c Binary files /dev/null and b/_deploy/sounds/recieve on 1.wav differ diff --git a/_deploy/sounds/recieve on 2.wav b/_deploy/sounds/recieve on 2.wav new file mode 100644 index 0000000..c8b8ce6 Binary files /dev/null and b/_deploy/sounds/recieve on 2.wav differ diff --git a/_deploy/sounds/record start 1.wav b/_deploy/sounds/record start 1.wav new file mode 100644 index 0000000..966ea49 Binary files /dev/null and b/_deploy/sounds/record start 1.wav differ diff --git a/_deploy/sounds/record start 2.wav b/_deploy/sounds/record start 2.wav new file mode 100644 index 0000000..20fca1b Binary files /dev/null and b/_deploy/sounds/record start 2.wav differ diff --git a/_deploy/sounds/record stop 1.wav b/_deploy/sounds/record stop 1.wav new file mode 100644 index 0000000..e955761 Binary files /dev/null and b/_deploy/sounds/record stop 1.wav differ diff --git a/_deploy/sounds/record stop 2.wav b/_deploy/sounds/record stop 2.wav new file mode 100644 index 0000000..5edcd4e Binary files /dev/null and b/_deploy/sounds/record stop 2.wav differ diff --git a/_deploy/sounds/save 1.wav b/_deploy/sounds/save 1.wav new file mode 100644 index 0000000..dc27f15 Binary files /dev/null and b/_deploy/sounds/save 1.wav differ diff --git a/_deploy/sounds/save 2.wav b/_deploy/sounds/save 2.wav new file mode 100644 index 0000000..7e13f4c Binary files /dev/null and b/_deploy/sounds/save 2.wav differ diff --git a/_deploy/sounds/send off 1.wav b/_deploy/sounds/send off 1.wav new file mode 100644 index 0000000..207f505 Binary files /dev/null and b/_deploy/sounds/send off 1.wav differ diff --git a/_deploy/sounds/send off 2.wav b/_deploy/sounds/send off 2.wav new file mode 100644 index 0000000..f5813f0 Binary files /dev/null and b/_deploy/sounds/send off 2.wav differ diff --git a/_deploy/sounds/send on 1.wav b/_deploy/sounds/send on 1.wav new file mode 100644 index 0000000..04807c5 Binary files /dev/null and b/_deploy/sounds/send on 1.wav differ diff --git a/_deploy/sounds/send on 2.wav b/_deploy/sounds/send on 2.wav new file mode 100644 index 0000000..f873eba Binary files /dev/null and b/_deploy/sounds/send on 2.wav differ diff --git a/_deploy/sounds/start up 1.wav b/_deploy/sounds/start up 1.wav new file mode 100644 index 0000000..66e3854 Binary files /dev/null and b/_deploy/sounds/start up 1.wav differ diff --git a/_deploy/sounds/start up 2.wav b/_deploy/sounds/start up 2.wav new file mode 100644 index 0000000..2f39340 Binary files /dev/null and b/_deploy/sounds/start up 2.wav differ diff --git a/_deploy/sounds/uncheck 1.wav b/_deploy/sounds/uncheck 1.wav new file mode 100644 index 0000000..375b39b Binary files /dev/null and b/_deploy/sounds/uncheck 1.wav differ diff --git a/_deploy/sounds/uncheck 2.wav b/_deploy/sounds/uncheck 2.wav new file mode 100644 index 0000000..1b7de63 Binary files /dev/null and b/_deploy/sounds/uncheck 2.wav differ diff --git a/_deploy/sounds/update 1.wav b/_deploy/sounds/update 1.wav new file mode 100644 index 0000000..8e2373e Binary files /dev/null and b/_deploy/sounds/update 1.wav differ diff --git a/_deploy/sounds/update 2.wav b/_deploy/sounds/update 2.wav new file mode 100644 index 0000000..9466858 Binary files /dev/null and b/_deploy/sounds/update 2.wav differ diff --git a/default sounds/tab switch 1.wav b/default sounds/tab switch 1.wav index 837e3fa..9739d89 100644 Binary files a/default sounds/tab switch 1.wav and b/default sounds/tab switch 1.wav differ diff --git a/deploy-test.ps1 b/deploy-test.ps1 new file mode 100644 index 0000000..3156725 --- /dev/null +++ b/deploy-test.ps1 @@ -0,0 +1,65 @@ +# deploy-test.ps1 - Ed's test-deploy. proj\remsound\default sounds is the AUTHORITATIVE sound master. +# +# As of 2026-06-13 the shipped DEFAULT cue sounds live in a "default sounds\" folder next to the exe +# (AppConfig.SoundsDirectory) - they're part of the install, not per-user state, so an update (or a +# republish) always overwrites them and a tweaked default always lands. The user's OWN custom sounds +# are NOT here: they're explicit file paths set via the Preferences "Browse" picker, kept in the +# user's own location, which nothing here touches. +# +# Every run of THIS script force-copies the ENTIRE source "default sounds\" folder over BOTH test +# locations' copies, overwriting every file regardless of timestamp/size (/IS /IT). Ed can tweak any +# number of sounds, with any timestamps, without telling anyone which changed - the next deploy +# always takes them all. (A running app reads each WAV fresh on every play, so a sound tweak even +# takes effect live, no restart needed.) Binaries + program files go out too, skipped automatically +# if RemSound is open and has them locked. +# +# Run: powershell -ExecutionPolicy Bypass -File deploy-test.ps1 + +$ErrorActionPreference = 'Stop' +$repo = $PSScriptRoot +$proj = Join-Path $repo 'src\RemSound.App\RemSound.App.csproj' +$srcSounds = Join-Path $repo 'default sounds' +$publish = Join-Path $repo 'publish' + +# The TWO test run-locations. Each has its own 'default sounds\' copy that must be kept current. +$runLocations = @($publish, 'D:\Dropbox\remsound') + +function Invoke-Robocopy([string[]]$rcArgs) { + & robocopy @rcArgs /NFL /NDL /NJH /NJS /NP /R:3 /W:1 | Out-Null + if ($LASTEXITCODE -ge 8) { throw "robocopy failed ($LASTEXITCODE): $($rcArgs -join ' ')" } +} + +$soundOnly = @(Get-Process RemSound -ErrorAction SilentlyContinue).Count -gt 0 +if ($soundOnly) { + Write-Host "RemSound is running - refreshing SOUNDS only (binaries are locked; close RemSound to update them)." -ForegroundColor Yellow +} +else { + Write-Host "Publishing (Release) -> $publish ..." -ForegroundColor Cyan + & dotnet publish $proj -c Release -o $publish --nologo -v q + if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed ($LASTEXITCODE)" } +} + +foreach ($loc in $runLocations) { + # Binaries: publish\ already has them from the publish above; copy them to the other location(s). + if (-not $soundOnly -and $loc -ne $publish) { + Write-Host "Deploying program + binaries -> $loc (preserving user settings/logs) ..." -ForegroundColor Cyan + Invoke-Robocopy @($publish, $loc, '/E') # no /MIR: never delete the user's settings/logs/profiles + } + # THE point of this script: the whole source 'default sounds' folder, force-copied over THIS + # location's copy, every time. /IS = copy even files robocopy thinks are identical; /IT = copy + # "tweaked" files. No /XO (don't skip on timestamp) and no /MIR (don't delete files). + $locSounds = Join-Path $loc 'default sounds' + Write-Host "Force-syncing 'default sounds' -> $locSounds (source wins, every time) ..." -ForegroundColor Cyan + New-Item -ItemType Directory -Path $locSounds -Force | Out-Null + Invoke-Robocopy @($srcSounds, $locSounds, '*.wav', '/IS', '/IT') + + # One-time tidy: drop the orphaned pre-2026-06-13 program 'sounds\' folder and the defunct old + # per-user sounds folder if they're lingering from before the move (the running app also deletes + # the per-user one on launch). Idempotent - a no-op once gone. + foreach ($orphan in @((Join-Path $loc 'sounds'), (Join-Path $loc 'user settings and logs\sounds'))) { + if (Test-Path -LiteralPath $orphan) { Remove-Item -LiteralPath $orphan -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +$count = @(Get-ChildItem $srcSounds -Filter *.wav).Count +Write-Host "Done. $count default sounds force-synced to BOTH test locations. Tweak away - the next run always takes them all." -ForegroundColor Green diff --git a/pi server/README.md b/pi server/README.md new file mode 100644 index 0000000..5e725dd --- /dev/null +++ b/pi server/README.md @@ -0,0 +1,164 @@ +# RemSound UDP Relay — `server-v2.0` + +A small UDP reflector that lets RemSound peers reach each other across the +internet without Tailscale in the audio path. Two modes in one binary: + +- **v1 (pairwise)** — the original two-slot reflector. First two endpoints + to send a valid RemSound v1 packet claim the slots; their traffic gets + mirrored to each other. Used by RemSound clients up to v1.x. +- **v2 (lobby)** — a multi-peer lobby (default cap: 10) keyed on a + per-instance CLIENT_ID. Used by RemSound clients that emit v2 packets. + Periodic LobbyRoster packets keep clients informed of who's in. + +A single relay instance handles both protocols concurrently on the same UDP +port. v1 clients keep working unchanged; v2 clients get the lobby model. +(A v1 client and a v2 client cannot hear each other in the same session; +that's a deliberate scope cut for this release.) + +## What's in this bundle + +| File | Purpose | +| ------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `remsound-relay.py` | the relay itself (dual-protocol) | +| `remsound-relay.service` | systemd unit for the relay | +| `remsound-relay-update.sh` | auto-updater. Polls GitHub Releases hourly for newer `server-*` tags and installs them in place | +| `remsound-relay-update.service` | systemd one-shot unit fired by the timer | +| `remsound-relay-update.timer` | the schedule (boot + every hour, with random jitter) | +| `install.sh` | sets up the relay AND the auto-updater | +| `uninstall.sh` | removes everything this bundle installed | +| `smoke-test.sh` | post-install sanity check (v1 + v2 paths + updater scaffolding) | +| `VERSION` | the tag this bundle represents (`server-v2.0`) | +| `README.md` | this file | + +## Installing on a fresh host + +Tested on Raspberry Pi OS Bookworm and Debian / Ubuntu. Requires `python3` +and `curl` (both usually preinstalled). + +```bash +# 1. Download and extract the latest server tarball. +curl -L -o /tmp/remsound-server.tar.gz \ + https://github.com/Ednunp/RemSound/releases/download/server-v2.0/remsound-server-v2.0.tar.gz +tar xzf /tmp/remsound-server.tar.gz -C /tmp +cd /tmp/remsound-server-v2.0 + +# 2. Install — sets up the relay AND auto-updates. +sudo ./install.sh + +# 3. Open UDP 47830 in the firewall and / or router port-forward. +# On UFW: sudo ufw allow 47830/udp +# On UDM / pfSense / etc.: WAN UDP 47830 -> this host's LAN IP. + +# 4. Confirm it's alive. +sudo ./smoke-test.sh +``` + +After install, the auto-updater is enabled. Future releases roll out +automatically — no manual SCP, no manual edit. To pin to the current +version: + +```bash +sudo systemctl disable --now remsound-relay-update.timer +``` + +To check for updates manually: + +```bash +sudo systemctl start remsound-relay-update.service +``` + +To uninstall everything cleanly: + +```bash +cd /tmp/remsound-server-v2.0 # or wherever the bundle is +sudo ./uninstall.sh +``` + +## Files on disk after install + +| Path | Purpose | +| --------------------------------------------------- | -------------------------------------- | +| `/usr/local/sbin/remsound-relay.py` | the relay | +| `/usr/local/sbin/remsound-relay-update.sh` | the auto-updater | +| `/etc/systemd/system/remsound-relay.service` | relay unit | +| `/etc/systemd/system/remsound-relay-update.service` | updater unit | +| `/etc/systemd/system/remsound-relay-update.timer` | updater schedule | +| `/etc/remsound-relay/version` | currently installed tag | +| `/etc/remsound-relay/backup/` | snapshot for the updater's rollback | +| `/var/log/remsound-relay.log` | relay event log (`event=...` per line) | +| `/var/log/remsound-relay-update.log` | update-check history | + +## Networking + +- Listens on UDP `47830` (chosen to avoid clashes with RemSound's own + defaults — 47820/47821/47822 — plus NetFlow 2055 and NUT 3493). +- Bound to `0.0.0.0`, so the kernel routes via the default interface. + Tailscale must NOT carry this traffic — the whole point of the relay is + to remove Tailscale's hops from the audio path. +- The relay process itself never decodes audio. It validates the + RemSound header (magic `RMND`, version 1 or 2) and forwards or drops. + +## Log format + +Structured key=value lines. Notable events: + +``` +event=startup version_supported=v1,v2 listen=0.0.0.0:47830 max_clients=10 + +# v1 (pairwise) +event=peer_joined addr=1.2.3.4:5555 slots_filled=1 +event=peer_paired a=1.2.3.4:5555 b=5.6.7.8:9999 +event=peer_dropped reason=idle addr=1.2.3.4:5555 remaining=1 +event=peer_replaced old=1.2.3.4:5555 new=9.8.7.6:4444 + +# v2 (lobby) +event=client_joined client_id= addr=1.2.3.4:5555 count=2 +event=client_endpoint_update client_id= old=1.2.3.4:5555 new=1.2.3.4:6666 +event=client_named client_id= name=Andre +event=client_left client_id= addr=... reason=bye +event=client_idle_expired client_id= addr=... +event=lobby_full attempted_client_id= addr=... count=10 max=10 + +# once a minute +event=stats forwarded=N dropped_unpaired=N dropped_lobby_full=N + rejected_bad_header=N pair_changes=N lobby_changes=N + client_count=N v1_peers=[...] v2_clients=[...] +``` + +Never logs CLIENT_ID payload bytes beyond the UUID itself. Never logs audio +payload. + +## Tunables + +| Setting | How to override | +| ------------------------ | ------------------------------------------------------------------------ | +| Listen port (47830) | `ExecStart=` `--port=N` in the service unit | +| Listen address | `ExecStart=` `--host=X` in the service unit | +| Lobby capacity (10) | `--max-clients=N` or env var `REMSOUND_MAX_CLIENTS=N` in the service unit | +| Idle timeout (60 s) | edit `IDLE_TIMEOUT_SECONDS` in `remsound-relay.py` | +| Stats interval (60 s) | edit `STATS_INTERVAL_SECONDS` in `remsound-relay.py` | +| Update check (hourly) | edit `remsound-relay-update.timer` | +| Update repo (Ednunp/RemSound) | env var `REMSOUND_UPDATE_REPO` in the updater service unit | + +## Why an auto-updater + +The relay is small and (after this release) doesn't change often, but when +it does we'd rather not chase every operator to re-SCP. The updater polls +GitHub Releases for tags starting with `server-`, finds the highest version, +downloads it, swaps the files, restarts the service, and falls back to the +prior version if startup fails. Logs everything to +`/var/log/remsound-relay-update.log`. + +It only triggers on `server-*` tags, so RemSound client releases (`v1.x`, +`v2.x` without the `server-` prefix) don't affect the relay. + +## Disabling auto-updates + +Either disable the timer: + +```bash +sudo systemctl disable --now remsound-relay-update.timer +``` + +…or run `uninstall.sh` (removes the updater scaffolding entirely along +with the relay). diff --git a/pi server/VERSION b/pi server/VERSION new file mode 100644 index 0000000..53a63ce --- /dev/null +++ b/pi server/VERSION @@ -0,0 +1 @@ +server-v2.3 diff --git a/pi server/install.sh b/pi server/install.sh new file mode 100644 index 0000000..4a8c508 --- /dev/null +++ b/pi server/install.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# install.sh — RemSound UDP relay installer for a stock Raspberry Pi (or any +# systemd Linux). Idempotent: re-running it just refreshes the files and +# restarts the service. +# +# Run with sudo from inside this folder: +# sudo ./install.sh +# +# What it does: +# 1. Sanity checks: systemd present, python3 present, curl present. +# 2. Copies remsound-relay.py to /usr/local/sbin/. +# 3. Copies remsound-relay.service to /etc/systemd/system/. +# 4. Copies the auto-updater script + service + timer. +# 5. Creates empty log files. +# 6. Writes /etc/remsound-relay/version with the bundled tag. +# 7. Snapshots the current install to /etc/remsound-relay/backup/ so the +# auto-updater has somewhere to roll back to on a bad future release. +# 8. systemctl daemon-reload, enable + start the relay + updater timer. +# 9. Prints status and the last few log lines. + +set -euo pipefail + +if [[ $EUID -ne 0 ]]; then + echo "This installer must run as root. Try: sudo ./install.sh" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" + +REQUIRED=( + remsound-relay.py + remsound-relay.service + remsound-relay-update.sh + remsound-relay-update.service + remsound-relay-update.timer + VERSION +) +for f in "${REQUIRED[@]}"; do + if [[ ! -f "$SCRIPT_DIR/$f" ]]; then + echo "Bundle is missing $f. Run the installer from inside the unzipped bundle folder." >&2 + exit 1 + fi +done + +if ! command -v systemctl >/dev/null 2>&1; then + echo "systemctl not found. This bundle expects a systemd-based Linux." >&2 + exit 1 +fi +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 not found. Install it first: sudo apt-get install -y python3" >&2 + exit 1 +fi +if ! command -v curl >/dev/null 2>&1; then + echo "curl not found. Install it first: sudo apt-get install -y curl" >&2 + exit 1 +fi + +VERSION_TAG="$(tr -d '[:space:]' < "$SCRIPT_DIR/VERSION")" +echo "Installing RemSound relay bundle $VERSION_TAG ..." + +echo "Installing relay script to /usr/local/sbin/remsound-relay.py ..." +install -o root -g root -m 755 "$SCRIPT_DIR/remsound-relay.py" /usr/local/sbin/remsound-relay.py +python3 -m py_compile /usr/local/sbin/remsound-relay.py + +echo "Installing systemd unit to /etc/systemd/system/remsound-relay.service ..." +install -o root -g root -m 644 "$SCRIPT_DIR/remsound-relay.service" /etc/systemd/system/remsound-relay.service + +echo "Installing auto-updater script to /usr/local/sbin/remsound-relay-update.sh ..." +install -o root -g root -m 755 "$SCRIPT_DIR/remsound-relay-update.sh" /usr/local/sbin/remsound-relay-update.sh + +echo "Installing auto-updater systemd units ..." +install -o root -g root -m 644 "$SCRIPT_DIR/remsound-relay-update.service" /etc/systemd/system/remsound-relay-update.service +install -o root -g root -m 644 "$SCRIPT_DIR/remsound-relay-update.timer" /etc/systemd/system/remsound-relay-update.timer + +echo "Ensuring log files exist ..." +touch /var/log/remsound-relay.log /var/log/remsound-relay-update.log +chown root:root /var/log/remsound-relay.log /var/log/remsound-relay-update.log +chmod 0644 /var/log/remsound-relay.log /var/log/remsound-relay-update.log + +echo "Writing version stamp ..." +mkdir -p /etc/remsound-relay /etc/remsound-relay/backup +printf '%s\n' "$VERSION_TAG" > /etc/remsound-relay/version + +echo "Snapshotting installed files to /etc/remsound-relay/backup/ for rollback ..." +rm -rf /etc/remsound-relay/backup +mkdir -p /etc/remsound-relay/backup +for f in \ + /usr/local/sbin/remsound-relay.py \ + /usr/local/sbin/remsound-relay-update.sh \ + /etc/systemd/system/remsound-relay.service \ + /etc/systemd/system/remsound-relay-update.service \ + /etc/systemd/system/remsound-relay-update.timer \ + /etc/remsound-relay/version +do + if [[ -f "$f" ]]; then + cp -a "$f" "/etc/remsound-relay/backup/$(basename "$f")" + fi +done + +echo "Reloading systemd ..." +systemctl daemon-reload + +echo "Enabling and starting remsound-relay.service ..." +systemctl enable remsound-relay.service >/dev/null +systemctl restart remsound-relay.service + +echo "Enabling and starting remsound-relay-update.timer ..." +systemctl enable remsound-relay-update.timer >/dev/null +systemctl restart remsound-relay-update.timer + +sleep 1 + +echo +echo "=== relay service status ===" +systemctl --no-pager --full status remsound-relay.service || true + +echo +echo "=== updater timer status ===" +systemctl --no-pager status remsound-relay-update.timer || true + +echo +echo "=== last 10 relay log lines ===" +tail -n 10 /var/log/remsound-relay.log 2>/dev/null || echo "(log empty so far)" + +echo +echo "=== listening socket check ===" +if command -v ss >/dev/null 2>&1; then + ss -lunp 2>/dev/null | grep ':47830' || echo "WARNING: no listener on UDP 47830 — see service status above." +else + echo "(ss not installed, skipping)" +fi + +echo +echo "Install complete. The relay is running on UDP 47830." +echo "Installed version: $VERSION_TAG" +echo "Auto-updates: enabled (hourly via remsound-relay-update.timer)." +echo +echo "To disable auto-updates: sudo systemctl disable --now remsound-relay-update.timer" +echo "To check for updates manually: sudo systemctl start remsound-relay-update.service" +echo "Update history log: /var/log/remsound-relay-update.log" +echo "See README.md for full operational notes." diff --git a/pi server/pi server update.md b/pi server/pi server update.md new file mode 100644 index 0000000..8112eb4 --- /dev/null +++ b/pi server/pi server update.md @@ -0,0 +1,199 @@ +# RemSound Pi Server — Handover + +**Status: server side COMPLETE.** Built, released, deployed, verified — 15 May 2026. + +This document records the upgrade of the RemSound relay server from the original +two-peer reflector to a dual-protocol (v1 pairwise + v2 lobby) relay with a +GitHub-based auto-updater. It is written for the RemSound thread, for Andre, and +for any future maintainer. + +The work was done from the **Pi thread** (the one that manages Ed's Raspberry Pi). +The design it was built from is the companion file `remsound server update.md`. + +--- + +## 1. What was built + +A single relay binary, `remsound-relay.py`, that handles **two protocol versions +concurrently** on the same UDP listener (port 47830): + +- **v1 (pairwise)** — the original two-slot reflector, **unchanged**. First two + UDP endpoints to send a valid RemSound v1 packet claim the slots; their + traffic is mirrored to each other. Existing RemSound clients (v1.x) keep + working against the new server with no changes. +- **v2 (lobby)** — a multi-peer lobby, up to 10 peers (configurable), keyed on a + per-instance `CLIENT_ID` (UUID). Every packet from one client is fanned out to + every other client in the lobby. NAT rebinds and "two clients behind one NAT" + stop being special cases because identity is the CLIENT_ID, not the endpoint. + Periodic `LobbyRoster` packets keep clients informed of membership. + +The server inspects byte 4 of each packet (the version byte) and routes v1 vs v2 +accordingly. A v1 client and a v2 client **cannot** share a lobby in this release +— that is a deliberate scope cut (see the design doc). + +Also built: + +- **Auto-updater** — `remsound-relay-update.sh`, run by a systemd timer. It polls + the GitHub Releases API hourly for tags beginning `server-`, and if a newer one + exists, downloads it, backs up the current install, swaps the files, restarts + the service, health-checks it, and **rolls back automatically** if the service + fails to come up. +- **systemd units** — `remsound-relay-update.service` (one-shot) + + `remsound-relay-update.timer` (boot + 2 min, then hourly with 10-min jitter). +- **Installer / uninstaller / smoke-test** — `install.sh`, `uninstall.sh`, + `smoke-test.sh`, updated to wire in the auto-updater. +- **`VERSION`** — the tag the bundle represents. + +--- + +## 2. Where everything is + +| Thing | Location | +| --- | --- | +| GitHub releases (what the auto-updater pulls) | `github.com/Ednunp/RemSound/releases` — tags `server-v2.0` … `server-v2.3` | +| GitHub source | `github.com/Ednunp/RemSound` → `server/` folder | +| Deployed and running | Ed's Raspberry Pi (`Pi5`), `server-v2.3`, UDP 47830 | +| Deployable bundle (this archive) | the files alongside this document | +| Design / spec | `remsound server update.md` (alongside this document) | + +**Note for the RemSound thread:** the repo's `server/` folder was updated by the +Pi thread on 15 May 2026 directly via the GitHub API. The **local checkout** at +`D:\proj\remsound\server\` may therefore be behind the remote — do a `git pull` +to sync before doing any work there, and do **not** overwrite `server/` with +older server code. + +--- + +## 3. The releases + +| Tag | What it is | +| --- | --- | +| `server-v2.0` | Initial dual-protocol release | +| `server-v2.1` | No-op test release (used to validate the auto-updater's upgrade path) | +| `server-v2.2` | Bug fix — see section 6 | +| `server-v2.3` | No-op test release (validated the fixed updater) | + +The Pi runs `server-v2.3`. The auto-updater always picks the **highest** version, +so the test releases don't interfere. **Future real releases should be `server-v2.4` +and upward.** + +--- + +## 4. IMPORTANT — what is still left, and whose job it is + +The **server is finished**. What remains is **client-side work in the RemSound +application**, and that is the **RemSound thread's job**, not the Pi thread's. + +Per the design doc, sections 5 and 9, the client work is: + +1. `Profile` / `AppConfig`: a new `ClientId` (Guid) field, generated once and + persisted in `remsound.config.json`. +2. `RemPacket`: header read/write learns the v2 format (28-byte header with the + 16-byte CLIENT_ID). v1 read/write stays. +3. `AudioSender`: send one stream to the lobby server; drop the per-peer fan-out. +4. `AudioReceiver` / `StreamSession`: re-key sessions on `(CLIENT_ID, streamId)` + instead of `(IPEndPoint, streamId)`. +5. `Connectivity` tab: a "Lobby" section showing the current `LobbyRoster`. + +None of this blocks anything: v1 clients keep working against the new server, so +the client work can land in its own release whenever convenient (the design doc +suggests `v1.5` or `v2.0` of the client). + +--- + +## 5. Wire format (v1 vs v2) + +**v1 header — 12 bytes, unchanged:** + +``` +0 4 MAGIC = 'RMND' +4 1 VERSION = 1 +5 1 TYPE (Format=1, Audio=2, KeepAlive=3, Heartbeat=4, Control=5) +6 2 STREAM_ID (LE) +8 4 SEQUENCE (LE) +12 payload... +``` + +**v2 header — 28 bytes:** + +``` +0 4 MAGIC = 'RMND' +4 1 VERSION = 2 +5 1 TYPE (1-5 as v1, plus LobbyHello=6, LobbyRoster=7, + LobbyFull=8, LobbyBye=9) +6 2 STREAM_ID (LE) +8 4 SEQUENCE (LE) +12 16 CLIENT_ID (UUID, RFC 4122 binary form) +28 payload... +``` + +v2 packet types the server originates use a zero CLIENT_ID +(`00000000-0000-0000-0000-000000000000`) so clients can recognise "from server". + +--- + +## 6. The bug we hit (for the record) + +During release validation, the auto-updater (`remsound-relay-update.sh`) was +found to exit with status 1 **after a successful upgrade**. Cause: the EXIT trap +referenced a variable (`work`) that had been declared `local` inside a function; +by the time bash fired the EXIT trap (after the function returned) the variable +was out of scope, so `set -u` raised `unbound variable` and bash exited 1. systemd +then marked the one-shot service as failed even though the upgrade had completed +correctly. + +Fixed in `server-v2.2`: the working-directory variable was promoted to script +scope (`WORK_DIR`) and the cleanup trap registered at script-global level. The +clean upgrade path was re-verified on `server-v2.2` → `server-v2.3`. + +--- + +## 7. Cutting a future server release + +```bash +# 1. Edit the bundle source. Bump VERSION to the new tag, e.g. "server-v2.4". +# 2. Tar it with the correct internal directory name: +tar -czf /tmp/remsound-server-v2.4.tar.gz \ + --transform 's,^,remsound-server-v2.4,' +# 3. Publish the release: +gh release create server-v2.4 /tmp/remsound-server-v2.4.tar.gz \ + --repo Ednunp/RemSound --title "Server v2.4 — ..." --notes "..." +# 4. Within ~1 hour every running relay's auto-updater picks it up, +# installs it, restarts, and rolls back automatically if it fails. +``` + +The asset must be named `remsound-server-*.tar.gz` and must contain a single +top-level folder. The updater finds the highest `server-*` tag, so version +numbers must keep climbing. + +--- + +## 8. Installing on a fresh box (e.g. Andre's Linux server) + +The deployable files sit alongside this document. On the target machine: + +```bash +sudo ./install.sh # sets up the relay AND the auto-updater +sudo ./smoke-test.sh # confirms it's alive +# then open UDP 47830 in the firewall / router toward this host +``` + +After that the box auto-updates from GitHub — no manual intervention ever again. +To pin a version: `sudo systemctl disable --now remsound-relay-update.timer`. +To remove everything: `sudo ./uninstall.sh`. + +Full operational detail is in the bundle's own `README.md`. + +--- + +## 9. Verification done + +- Initial install on the Pi via `install.sh` — smoke test all green. +- v1 synthetic packet accepted (logged `peer_joined`); v2 synthetic packet + accepted (logged `client_joined`). +- Auto-updater "up to date" path — clean exit. +- Auto-updater upgrade path — `server-v2.0` → `v2.1` → `v2.2` → `v2.3`, each + step verified, post-fix runs exit `0/SUCCESS`, version stamp updates, rollback + snapshot in place. +- The relay survived a whole-house power cut on 15 May 2026 and came back on + `server-v2.3` automatically. diff --git a/pi server/remsound server update.md b/pi server/remsound server update.md new file mode 100644 index 0000000..9e463e1 --- /dev/null +++ b/pi server/remsound server update.md @@ -0,0 +1,480 @@ +# RemSound relay upgrade — lobby model with auto-updates + +Design + ops handover for upgrading the existing `remsound-relay.py` from a two-slot pairing reflector to a small lobby-style multi-peer relay. Same bundle works on a Raspberry Pi (the existing deployment) and on a full Linux host (Andre's box). Includes a self-update mechanism so once a host is on the new bundle, future releases roll out without anyone manually copying files. + +The existing two-slot relay (`server/remsound-relay.py` on GitHub) stays usable. The lobby relay is a parallel script — same package, additional file — so existing hosts that just want a private two-peer relay keep working unmodified. + +--- + +## 1. What's changing and why + +### Current state + +`remsound-relay.py` has two peer slots. The first two UDP endpoints to send a valid RemSound packet claim the slots; everything from one slot is reflected to the other. Third endpoint is dropped silently. Slots get reclaimed after 60 s idle. + +This means: + +- Hard cap of two connected peers per relay. +- Two clients behind the same NAT can both register (different ephemeral ports), but they get paired with each other instead of with the intended remote peer. +- A third RemSound instance joining an active pair gets ignored with no feedback. + +### What we want + +- Any number of RemSound instances (up to a configurable cap, default 10) all able to be in the same conversation through one server. +- Each instance is identified by a stable client ID, not by its network endpoint. NAT rebinds, network switches, and same-NAT-multiple-clients all stop being special cases. +- The relay forwards each packet to every other registered client. No mixing on the server. Clients use `PlayoutEngine`'s existing per-session mixing exactly as they do today. +- The relay logs joins, leaves, and per-minute stats. It never decodes audio and never logs payload bytes. +- The relay auto-updates: when a new release is published on GitHub, every running relay picks it up within an hour and restarts itself. + +### What we're not adding (yet) + +- Rooms / channels. One server = one lobby. Want a second lobby? Run a second instance on a different port. +- Server-side mixing. Forwarding is simpler, faster, and matches RemSound's existing client-side mix path. +- Voice activation / push-to-talk gating at the server. +- Authentication or lobby passwords. + +All of those are future work and don't need to block this change. + +--- + +## 2. Lobby model + +### Capacity + +- Cap = 10 clients per lobby. Configurable on the server (`--max-clients`). +- 11th client gets a `LobbyFull` control packet back with the current member count. Client surfaces a "Lobby is full (10 / 10)" message; no queue. +- At PCM-stereo (~1.5 Mbps/stream) a 10-peer lobby is ~13.5 Mbps downlink per client. Opus 192 kbps brings that down to ~1.7 Mbps. Both fine on consumer broadband. + +### Identity + +Each RemSound instance generates a UUID once and persists it in `remsound.config.json` (machine-local, not per-profile, not part of the profile JSON). Survives reboots, profile switches, network changes. Two instances on the same machine have distinct IDs — that's exactly how the "DT + LT both behind same NAT" case stops being special. + +### Forwarding rule + +- Every packet from a registered client gets forwarded to every other registered client in the same lobby. Unchanged bytes, including the original sender's CLIENT_ID, so receivers know who the audio came from. +- Sender does not need a destination peer list any more. It sends one stream to the server; the server fan-outs. +- Receiver routes incoming packets to the matching `SessionPlayout` keyed by CLIENT_ID (instead of by `(endpoint, streamId)` as today). + +### Idle handling + +- Per-client idle timeout, 60 s (same as the current relay). Goes silent → expire that client. Doesn't affect anyone else in the lobby. +- Roster broadcast (see §4) fires whenever lobby membership changes, so other clients learn about joins / leaves promptly. + +--- + +## 3. Wire format v2 + +The relay must distinguish v1 packets (legacy `remsound-relay.py` clients) from v2 (lobby-aware clients) and route accordingly. + +### v1 header (existing, unchanged) + +``` +offset size field +0 4 MAGIC = 'RMND' +4 1 VERSION = 1 +5 1 TYPE (Format=1, Audio=2, KeepAlive=3, Heartbeat=4, Control=5) +6 2 STREAM_ID (LE) +8 4 SEQUENCE (LE) +12 payload... +``` + +Total 12 bytes. + +### v2 header (new) + +``` +offset size field +0 4 MAGIC = 'RMND' +4 1 VERSION = 2 +5 1 TYPE (Format=1, Audio=2, KeepAlive=3, Heartbeat=4, Control=5, + LobbyHello=6, LobbyRoster=7, LobbyFull=8, LobbyBye=9) +6 2 STREAM_ID (LE) +8 4 SEQUENCE (LE) +12 16 CLIENT_ID (UUID, RFC 4122 binary form, big-endian by convention) +28 payload... +``` + +Total 28 bytes. CLIENT_ID slot is the only structural addition. + +### Backward compatibility + +Server inspects byte 4 (VERSION): + +- `0x01` → v1 client. Apply the existing two-slot pairing logic, identical to today. Lets unmodified legacy clients keep working against the new server. +- `0x02` → v2 client. Apply lobby logic. + +A single relay instance handles both protocols concurrently. A v1 client and a v2 client cannot share a lobby in this release — that's a deliberate limitation. Practical answer: anyone running a fresh server runs the new bundle, and any client wanting to use a lobby upgrades to a v2-aware build. v1 clients keep working against the same server for pairwise use. + +### New packet types (v2 only) + +- **LobbyHello (6)** — client → server. Sent immediately after the first audio/format packet, but redundant if those were the first thing seen. Carries the client's display name (UTF-8, max 32 bytes, padded with null). Server uses this for roster announcements. +- **LobbyRoster (7)** — server → client. Sent on every membership change (someone joins, someone leaves, someone updates display name) and periodically at ~1 Hz heartbeat. Carries `count` followed by `count` × `{CLIENT_ID(16), display_name(32 bytes, null-padded UTF-8)}`. Max realistic size: 10 × 48 = 480 bytes + header = well under MTU. +- **LobbyFull (8)** — server → would-be client. Sent in response to a registration attempt when the lobby is at capacity. Carries `current_count(1) max_count(1)`. +- **LobbyBye (9)** — client → server, or server → client. Carries no payload (or just a reason code). Sent on graceful disconnect (client closing) or eviction (server pruning). + +These are all small, low-rate, never on the audio hot path. + +--- + +## 4. Server design + +### State + +```python +# Per-client entry. Identifies who, where, when last seen. +ClientEntry = ( + endpoint: (host, port), + display_name: str, + last_seen_monotonic: float, + rx_packets: int, + tx_packets: int, +) + +# The entire lobby. +clients: dict[uuid.UUID, ClientEntry] = {} +max_clients: int = 10 # configurable +``` + +Single flat dict, keyed by CLIENT_ID. No nested rooms. + +### Packet flow + +For an incoming UDP packet with `(data, addr)`: + +1. Parse header. If `MAGIC != 'RMND'`: drop (`rejected_bad_header++`). +2. If `VERSION == 1`: route through legacy two-slot logic (unchanged from today). +3. If `VERSION == 2`: + a. Read `CLIENT_ID` from bytes 12..28. + b. If `CLIENT_ID not in clients`: + - If `len(clients) >= max_clients`: send `LobbyFull` to `addr`, drop the original packet (`dropped_lobby_full++`). + - Else: insert new `ClientEntry(addr, display_name="", monotonic_now, 0, 0)`. Log `event=client_joined`. Schedule a roster broadcast. + c. Existing entry: refresh `endpoint` (handles NAT rebinding) and `last_seen_monotonic`. Bump `rx_packets`. + d. By packet type: + - `LobbyHello`: extract display name, store. Schedule a roster broadcast. + - `LobbyBye`: remove the entry. Log `event=client_left`. Schedule a roster broadcast. + - `Audio` / `Format` / `KeepAlive` / `Heartbeat` / `Control`: forward to every OTHER client in `clients`. Bump each recipient's `tx_packets`. + - Anything else: drop. + +### Roster broadcast + +- Sent whenever a join / leave / name update happens. +- Also sent periodically (every 1 s) as a heartbeat, so clients quietly detect disconnects when their roster goes empty. +- Built once per cycle, sent unmodified to every connected client. + +### Idle expiry + +- Once per main loop iteration: walk `clients`, remove any entry whose `last_seen` is older than `IDLE_TIMEOUT_SECONDS` (60). Same timeout as today. +- On removal, log `event=client_idle_expired` and schedule a roster broadcast. + +### Logging + +Same format as the current relay: structured key=value lines to `/var/log/remsound-relay.log` and stderr. New events: + +- `event=client_joined client_id= addr= name=` +- `event=client_left client_id=` +- `event=client_idle_expired client_id=` +- `event=lobby_full attempted_client_id= addr=` +- `event=stats forwarded=N dropped_lobby_full=N rejected_bad_header=N client_count=N peers=[...]` + +Never logs CLIENT_ID payload bytes beyond the UUID itself. Never logs audio payload. + +### Capacity check + +```python +def can_admit(client_id: uuid.UUID) -> bool: + return client_id in clients or len(clients) < max_clients +``` + +That's it. The cap is a soft limit at admit time; existing clients can never get evicted by a newcomer. + +### Approximate complexity + +For an active lobby of N peers, each audio packet from one client triggers (N - 1) `sock.sendto` calls. At 10 peers and PCM rates (~1500 packets/sec/client → 10 × 1500 = 15,000 inbound/sec → 10 × 9 × 1500 ≈ 135,000 outbound sendto's/sec). Comfortable on any modern Linux. Even a Pi 4 will handle it; a real server is bored. + +--- + +## 5. Client changes (high-level — actual implementation is a separate task) + +These are listed so Andre can see the full picture, not because the server bundle has to contain client code. + +- `Profile` / `AppConfig`: new `ClientId` field (Guid), persisted in `remsound.config.json`. Generated on first launch. +- `RemPacket`: header reading / writing learns v2 format. Old v1 reads/writes still supported. +- `AudioSender`: sends one stream to the lobby server. Drops the per-peer fan-out loop. +- `AudioReceiver` / `StreamSession`: dictionary key changes from `(IPEndPoint, ushort)` to `(Guid clientId, ushort streamId)`. Endpoint becomes a routing detail. +- `HeartbeatService`: peer-health tracking keyed by `ClientId`. +- `Connectivity` tab: "Lobby" section showing connected lobby members from the latest `LobbyRoster`. Replaces the per-pair "Discovered peers" model for lobby connections. LAN discovery still works for non-lobby pair sessions. +- `Selected peers` semantics unchanged: still a tick-to-accept allow-list, but keyed on CLIENT_ID rather than IP. + +A migration phase where v1 packets are still emitted lets older clients keep talking to the new server for pair use. + +--- + +## 6. Auto-update on the server + +### Goal + +Push a new server release to GitHub → every relay running this bundle picks it up within an hour and restarts itself onto the new version. Andre never has to re-SCP, never has to remember to update. + +### Mechanism + +Three new pieces ship in the bundle alongside `remsound-relay.py`: + +1. **`remsound-relay-update.sh`** — the updater script. Bash. Talks to GitHub. +2. **`remsound-relay-update.service`** — systemd unit that runs the updater once. +3. **`remsound-relay-update.timer`** — systemd timer firing the updater on a schedule. + +The relay binary itself doesn't reach out. Separation of concerns: the relay just relays; the updater just updates. If the updater is broken, the relay keeps running. If the relay crashes, the updater keeps trying to install fixes. + +### Updater script behaviour + +``` +remsound-relay-update.sh: + +1. Read current version from /etc/remsound-relay/version (file contains a single line like "server-v2.0"). + If the file is missing, treat the installed version as "server-v0". +2. Call GitHub API: https://api.github.com/repos/Ednunp/RemSound/releases + Filter releases whose tag starts with "server-". Take the highest by tag semver. +3. If latest <= current: log "up to date" and exit 0. +4. If latest > current: + a. Look in the release's assets for `remsound-server-.tar.gz`. If missing, log warning, exit 1. + b. curl the asset to /tmp/remsound-server-.tar.gz. + c. (Optional v2) Verify SHA256 against a second asset `remsound-server-.tar.gz.sha256`. + d. Extract to /tmp/remsound-server-/. + e. Stop the relay: systemctl stop remsound-relay. + f. Copy the new files into place (replacing /usr/local/sbin/remsound-relay.py and + /etc/systemd/system/remsound-relay.service if present in the asset). + g. systemctl daemon-reload, systemctl start remsound-relay. + h. Wait 3 seconds. Check the service is active. If yes: write the new tag to + /etc/remsound-relay/version, log success, clean up /tmp staging. + If no: roll back from a backup of the old files (kept at /etc/remsound-relay/backup/), + restart the service, log failure. Exit 1. +5. Log to /var/log/remsound-relay-update.log. +``` + +The updater self-update case (a new updater script is itself shipped in a release) is handled by the same copy step in 4f — the running updater finishes its current cycle, exits, and next cycle the new updater script is what runs. + +### Systemd timer + +``` +# remsound-relay-update.timer +[Unit] +Description=Periodic update check for remsound-relay + +[Timer] +OnBootSec=2min +OnUnitActiveSec=1h +RandomizedDelaySec=10min +Persistent=true + +[Install] +WantedBy=timers.target +``` + +- Fires 2 minutes after boot, then every hour thereafter. +- `RandomizedDelaySec=10min` so multiple relays don't all hit the GitHub API at the same exact instant if you ever run many. +- `Persistent=true` ensures missed runs (host was off) catch up on next boot. + +### Systemd service for the updater + +``` +# remsound-relay-update.service +[Unit] +Description=Check for and apply remsound-relay updates from GitHub +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/remsound-relay-update.sh +Restart=no +``` + +One-shot. Triggered only by the timer. Failure is logged but not retried — the next hourly tick has another go. + +### Release tag convention + +Server releases use the tag pattern `server-vMAJOR.MINOR` (e.g. `server-v2.0`, `server-v2.1`). Client releases keep their existing `vMAJOR.MINOR` pattern (`v1.4`, `v1.5`). The updater filter (`tag startswith "server-"`) makes the two streams orthogonal: client releases don't trigger relay updates, and vice versa. + +A release asset name is `remsound-server-.tar.gz`. Tarball contents: + +``` +remsound-server-v2.0/ +├── remsound-relay.py +├── remsound-relay.service +├── remsound-relay-update.sh +├── remsound-relay-update.service +├── remsound-relay-update.timer +├── install.sh +├── uninstall.sh +├── smoke-test.sh +├── VERSION # contains "server-v2.0\n" +└── README.md +``` + +### Rollback path + +On failed update (service won't come up after install), the updater restores from `/etc/remsound-relay/backup/`, which `install.sh` populates at install time (and which the updater itself refreshes before every replacement). If even that fails, manual recovery is the same as today: SSH in, `apt install python3` (already there), and copy the bundle from a working host or `git clone` the repo. + +### Disabling auto-update + +Anyone who wants the relay to stay pinned at a known version simply disables the timer: + +``` +sudo systemctl disable --now remsound-relay-update.timer +``` + +Service keeps running. No further updates. Re-enabling resumes the schedule. + +--- + +## 7. Installation on a fresh host + +### Raspberry Pi (or any Debian/Ubuntu/Pi OS) + +1. Download the latest server tarball from the GitHub release page (or have the updater do it after manual bootstrap). +2. Extract, `cd` into the folder, `sudo ./install.sh`. +3. Open UDP 47830 in the host's firewall and / or router. + +### Full Linux server (Andre's box) + +Same script. The differences are operational, not structural: + +- Andre will likely want a non-default port (e.g. 47830 is fine for one lobby; second instance on 47831 if needed). Add `--port` flag to `remsound-relay.service` `ExecStart=` line. +- He may want the log file under `/var/log/journal/` and a smaller log retention; that's a `journald.conf` concern, not the relay's. +- `max-clients` configurable via env var `REMSOUND_MAX_CLIENTS` so it can be set in the systemd unit without editing the script. + +### What `install.sh` does in the new bundle + +Adds three things to the existing five steps: + +6. Install `remsound-relay-update.sh` to `/usr/local/sbin/`. +7. Install `remsound-relay-update.service` and `remsound-relay-update.timer` to `/etc/systemd/system/`. +8. `systemctl enable --now remsound-relay-update.timer`. +9. Write the current bundle's version to `/etc/remsound-relay/version`. +10. Snapshot current files to `/etc/remsound-relay/backup/` for the updater's rollback path. + +`uninstall.sh` gets the matching teardown. + +### Smoke test + +`smoke-test.sh` checks: + +- Service is active. +- UDP 47830 has a listener. +- Log file is being written to. +- (New) Updater service exists and the timer is active. +- (New) `/etc/remsound-relay/version` is readable and matches expected format. + +--- + +## 8. GitHub release process + +When we cut a new server release: + +1. Bump version in `VERSION` and in `remsound-relay.py`'s startup-log line. +2. Tag: `git tag server-v2.1 && git push origin server-v2.1`. +3. `tar czf remsound-server-v2.1.tar.gz remsound-server-v2.1/` (where the folder is a staging area with the bundle contents). +4. `gh release create server-v2.1 remsound-server-v2.1.tar.gz --title "server v2.1" --notes "…"`. +5. Within an hour, every running relay's updater catches the release, downloads it, restarts the service. + +We can additionally publish a `.sha256` alongside if we want signature-style integrity checks; not strictly required because GitHub serves the tarball over HTTPS. + +### Release notes content + +Keep them short and operational: what changed, what the operator needs to know, anything they need to do manually (usually nothing — that's the whole point of auto-update). + +--- + +## 9. Migration phases + +Ordered so each step is independently testable and rollback-able. Server work is steps 1–3; client work is 4–6. + +1. **v2 wire format added to client side, still emits v1 by default.** No behaviour change. Just makes the new header reading / writing code paths exist. Backward-compatible with existing relays. +2. **New `remsound-lobby.py` ships as `server-v2.0`.** Handles v1 packets exactly like today's relay (two-slot pairing); handles v2 packets via the lobby logic. Initial deployment: install on onj.me alongside / replacing the current relay. Existing v1 clients keep working pairwise. +3. **Auto-updater bundle goes live.** Once steps 1 and 2 are in place, future server changes roll out without manual deployment. +4. **Client adds CLIENT_ID generation + persistence.** New UUID stamped on every outbound v2 packet. Server now sees the same client by ID even if endpoint changes. +5. **Client receiver re-keys sessions on CLIENT_ID.** Internal change. Lobby connections become a real first-class thing in the UI. +6. **Client UI: lobby tab / lobby roster integration.** Replace per-pair manual peer entry with a "connect to lobby" affordance. LAN discovery still works in parallel for non-lobby setups. + +Each phase is a separate commit (and release where appropriate). Steps 1–3 are server-side and can ship without any RemSound client release. Steps 4–6 are client-side; they should ship in a single client release (probably `v1.5` or `v2.0` depending on how disruptive the wire change feels). + +--- + +## 10. Testing + +### Server-side + +- **`smoke-test.sh`** runs the basic install-check (listener bound, service active, log present, updater timer enabled). +- **2-client v1 pair test**: existing test from the current bundle. Should still pass — proves backward compat. +- **3-client v2 lobby test**: bring up three RemSound instances (or three test scripts that mimic v2 packets), all dial the relay, confirm each receives the other two's audio. +- **Lobby-full test**: 11th client receives `LobbyFull`. Server doesn't crash. +- **Idle eviction test**: stop one client, wait 60 s, confirm the other clients receive an updated roster. +- **Auto-update dry-run**: tag a no-op server release, watch the timer fire, confirm the relay picks it up, restarts, and the new version shows in `/etc/remsound-relay/version`. + +### Client-side (once steps 4–6 land) + +- Two instances behind the same NAT both connect to the same lobby: each sees the other and the remote peer in the lobby roster. +- Network rebinding (Wi-Fi → Ethernet): client session stays alive; endpoint update is picked up by the server. +- Lobby-full: clean UI message. +- Mixed lobby of v2 clients only (initially we won't support mixing v1 and v2 in one lobby — it's a separate work stream if ever needed). + +### Production canary + +When `server-v2.0` is ready, ship it first to one of the relays (the Pi or onj.me, your call), watch logs for a day or two, then deploy to the other. Auto-update + atomic rollback means even a bad release doesn't take both hosts offline simultaneously. + +--- + +## 11. Open questions + +These should be answered before code starts but aren't blocking for design review. + +1. **Display names**: do we want them at all in v2.0, or defer to v2.1? They're nice for UI roster display but not load-bearing for routing. **Suggestion: defer.** Client sends `LobbyHello` with display name = profile name; server treats missing / empty display names as "Unknown" in the roster. +2. **Authentication**: do we need a shared secret to join a lobby on onj.me? **Suggestion: not for v2.0.** Anyone who knows the address can connect. If unwanted-strangers becomes a problem, add a `LobbyAuth(secret)` packet type in a later release. +3. **IPv6**: the existing relay binds IPv4 only. For Andre's full server it'd be reasonable to bind both. **Suggestion: optional dual-stack via `--bind6`. Default IPv4 to match existing behaviour.** +4. **Metrics endpoint**: would a Prometheus-style `/metrics` HTTP endpoint be useful for Andre? **Suggestion: not in v2.0. The per-minute stats line in the log file is enough until someone asks.** +5. **Recording at the server**: a server-side recording feature would be powerful (capture all lobby audio for later playback / archive) but adds the kind of complexity the rest of this design carefully avoids. **Suggestion: defer indefinitely.** +6. **Renegotiating CLIENT_ID**: if someone wants to wipe their identity (privacy / fresh start), the simplest answer is "delete the line from `remsound.config.json` and restart". No protocol-level renegotiation needed. + +--- + +## 12. Quick reference for Andre + +If you're reading this to set up your Linux box once the v2.0 release is out: + +``` +# 1. Download and extract the latest server bundle. +curl -L -o /tmp/remsound-server.tar.gz \ + https://github.com/Ednunp/RemSound/releases/download/server-v2.0/remsound-server-v2.0.tar.gz +tar xzf /tmp/remsound-server.tar.gz -C /tmp +cd /tmp/remsound-server-v2.0 + +# 2. Install. This sets up the relay AND the auto-updater. +sudo ./install.sh + +# 3. Open UDP 47830 in the firewall. +sudo ufw allow 47830/udp +# (or whatever your firewall is) + +# 4. Confirm it's alive. +sudo ./smoke-test.sh +``` + +After that you can forget about it. Future updates roll out automatically. If you want to pin a version, `sudo systemctl disable --now remsound-relay-update.timer`. If you want to remove it entirely, `sudo ./uninstall.sh`. + +Log file is `/var/log/remsound-relay.log` for the relay itself, `/var/log/remsound-relay-update.log` for the update history. Both rotate via the system's logrotate defaults. + +--- + +## 13. Status + +This is a design + handover document, not the implementation. None of the v2 code exists yet. When ready to build: + +- `remsound-lobby.py` (~250 lines Python) — the new lobby relay. +- `remsound-relay-update.sh` (~150 lines Bash) — the updater. +- Two systemd units (~30 lines total) — service + timer for the updater. +- `install.sh` / `uninstall.sh` / `smoke-test.sh` updates. +- Wire format v2 in `RemSound.Core.RemPacket` (~100 lines C# delta). +- Per-client UUID persistence + heartbeat / session refactor in the client (~few hundred lines). +- Client UI for the lobby tab (later phase, optional in v2.0 release). + +Total scope: a couple of focused days for the server bundle, several days for the client refactor + UI. Server-side ships first and runs alongside the existing two-slot relay without breaking it. diff --git a/pi server/remsound-relay-update.service b/pi server/remsound-relay-update.service new file mode 100644 index 0000000..cb2edf5 --- /dev/null +++ b/pi server/remsound-relay-update.service @@ -0,0 +1,13 @@ +[Unit] +Description=Check for and apply remsound-relay updates from GitHub +Documentation=file:///usr/local/sbin/remsound-relay-update.sh +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/remsound-relay-update.sh +Restart=no +# StandardOutput goes to the journal as well as the updater's log file. +StandardOutput=journal +StandardError=journal diff --git a/pi server/remsound-relay-update.sh b/pi server/remsound-relay-update.sh new file mode 100644 index 0000000..0dba8a3 --- /dev/null +++ b/pi server/remsound-relay-update.sh @@ -0,0 +1,358 @@ +#!/bin/bash +# remsound-relay-update.sh — periodic GitHub-release-based updater for the +# RemSound relay. Designed to run from a systemd .timer; safe to run by hand +# too. Idempotent: if there is no newer release, exits 0 quickly with no +# system changes. +# +# What it does: +# 1. Read the installed tag from /etc/remsound-relay/version. +# 2. Query the GitHub Releases API for tags starting with "server-". +# 3. If the latest is newer than the installed tag: +# a. Download the matching tarball asset. +# b. Snapshot current installed files to /etc/remsound-relay/backup/. +# c. Stop the relay service. +# d. Replace the relay files with the new tarball contents. +# e. systemctl daemon-reload + start. +# f. Wait 3s and check the service is active. +# If yes: write the new tag, log success, exit 0. +# If no: restore from backup, restart, log failure, exit 1. +# 4. Log everything to /var/log/remsound-relay-update.log. +# +# Failure modes are defensive — a broken updater run leaves the previously +# installed version running, never less. + +set -euo pipefail + +# -------- configuration ------------------------------------------------------ + +REPO="${REMSOUND_UPDATE_REPO:-Ednunp/RemSound}" +TAG_PREFIX="${REMSOUND_UPDATE_TAG_PREFIX:-server-}" +ASSET_PATTERN="${REMSOUND_UPDATE_ASSET_PATTERN:-remsound-server-.*\.tar\.gz$}" + +INSTALL_BIN="/usr/local/sbin/remsound-relay.py" +INSTALL_UPDATER="/usr/local/sbin/remsound-relay-update.sh" +INSTALL_SERVICE="/etc/systemd/system/remsound-relay.service" +INSTALL_UPDATE_SERVICE="/etc/systemd/system/remsound-relay-update.service" +INSTALL_UPDATE_TIMER="/etc/systemd/system/remsound-relay-update.timer" + +STATE_DIR="/etc/remsound-relay" +VERSION_FILE="$STATE_DIR/version" +BACKUP_DIR="$STATE_DIR/backup" +LOG_FILE="/var/log/remsound-relay-update.log" + +SERVICE_NAME="remsound-relay.service" +HEALTH_WAIT_SECONDS=3 + +# Script-global mktemp working directory. Set by main(); cleaned up by the +# EXIT trap below. Kept at script scope (not function-local) so the trap can +# still see it under `set -u` after main returns. +WORK_DIR="" + +cleanup_work_dir() { + if [[ -n "$WORK_DIR" && -d "$WORK_DIR" ]]; then + rm -rf "$WORK_DIR" + fi +} +trap cleanup_work_dir EXIT + +# -------- helpers ------------------------------------------------------------ + +log() { + local ts msg + ts="$(date '+%Y-%m-%d %H:%M:%S')" + msg="$ts $*" + printf '%s\n' "$msg" | tee -a "$LOG_FILE" >&2 +} + +require_root() { + if [[ $EUID -ne 0 ]]; then + log "ERROR: this script must run as root" + exit 1 + fi +} + +ensure_dirs() { + mkdir -p "$STATE_DIR" "$BACKUP_DIR" "$(dirname "$LOG_FILE")" + touch "$LOG_FILE" +} + +read_current_version() { + if [[ -f "$VERSION_FILE" ]]; then + local v + v="$(tr -d '[:space:]' < "$VERSION_FILE")" + if [[ -n "$v" ]]; then + printf '%s' "$v" + return + fi + fi + printf '%s' "${TAG_PREFIX}v0" +} + +# Parse a tag like "server-v2.10" into a comparable numeric form. +# Outputs MAJOR.MINOR; both default to 0 if the tag is unparseable. +tag_to_version() { + local tag="$1" + # strip the prefix + tag="${tag#"$TAG_PREFIX"}" + # strip a leading "v" if present + tag="${tag#v}" + local major minor + major="${tag%%.*}" + minor="${tag#*.}" + # if there's no dot, minor==major. Treat as MAJOR.0. + if [[ "$minor" == "$tag" ]]; then + minor="0" + fi + # keep only digits — survive things like "v2.0-rc1" by ignoring the suffix. + major="${major//[^0-9]/}" + minor="${minor//[^0-9]/}" + : "${major:=0}" + : "${minor:=0}" + printf '%s.%s' "$major" "$minor" +} + +# Returns 0 if $1 > $2 (i.e. left tag is newer), 1 otherwise. +tag_newer_than() { + local left right lv rv + left="$(tag_to_version "$1")" + right="$(tag_to_version "$2")" + # Numeric compare major then minor. + lv="${left%.*}"; rv="${right%.*}" + if (( lv > rv )); then return 0; fi + if (( lv < rv )); then return 1; fi + lv="${left#*.}"; rv="${right#*.}" + if (( lv > rv )); then return 0; fi + return 1 +} + +# -------- GitHub releases query --------------------------------------------- + +# Fetch the releases list and pick the latest server-tag release. +# Outputs three lines: tag, asset_url, asset_name. Exits non-zero on no match. +get_latest_release() { + local json + if ! json="$(curl --fail --silent --show-error --max-time 30 \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${REPO}/releases?per_page=30" 2>&1)"; then + log "ERROR: failed to query GitHub releases: $json" + return 2 + fi + + REPO="$REPO" TAG_PREFIX="$TAG_PREFIX" ASSET_PATTERN="$ASSET_PATTERN" \ + python3 - "$json" <<'PY' +import json, os, re, sys + +raw = sys.argv[1] if len(sys.argv) > 1 else "" +prefix = os.environ.get("TAG_PREFIX", "server-") +asset_re = re.compile(os.environ.get("ASSET_PATTERN", r"remsound-server-.*\.tar\.gz$")) + +try: + releases = json.loads(raw) +except Exception as exc: + sys.stderr.write(f"json parse failed: {exc}\n") + sys.exit(3) + +if not isinstance(releases, list): + sys.stderr.write(f"unexpected releases response shape\n") + sys.exit(3) + +def parse_version(tag): + # strip prefix + if tag.startswith(prefix): + tag = tag[len(prefix):] + if tag.startswith("v"): + tag = tag[1:] + parts = tag.split(".") + out = [] + for p in parts: + digits = "".join(c for c in p if c.isdigit()) + out.append(int(digits) if digits else 0) + if len(out) < 2: + out.append(0) + return tuple(out) + +candidates = [] +for r in releases: + if not isinstance(r, dict): + continue + tag = r.get("tag_name") or "" + if not tag.startswith(prefix): + continue + if r.get("draft") or r.get("prerelease"): + continue + asset = None + for a in r.get("assets") or []: + name = (a or {}).get("name") or "" + if asset_re.search(name): + asset = a + break + if asset is None: + continue + url = asset.get("browser_download_url") or "" + name = asset.get("name") or "" + if not url: + continue + candidates.append((parse_version(tag), tag, url, name)) + +if not candidates: + sys.stderr.write("no eligible server-* releases found\n") + sys.exit(4) + +candidates.sort(reverse=True) +_, tag, url, name = candidates[0] +print(tag) +print(url) +print(name) +PY +} + +# -------- backup + install --------------------------------------------------- + +snapshot_backup() { + log "snapshotting current install to $BACKUP_DIR" + rm -rf "$BACKUP_DIR" + mkdir -p "$BACKUP_DIR" + for f in \ + "$INSTALL_BIN" "$INSTALL_UPDATER" \ + "$INSTALL_SERVICE" "$INSTALL_UPDATE_SERVICE" "$INSTALL_UPDATE_TIMER" \ + "$VERSION_FILE" + do + if [[ -f "$f" ]]; then + cp -a "$f" "$BACKUP_DIR/$(basename "$f")" + fi + done +} + +restore_backup() { + log "rolling back from $BACKUP_DIR" + for f in \ + "$INSTALL_BIN" "$INSTALL_UPDATER" \ + "$INSTALL_SERVICE" "$INSTALL_UPDATE_SERVICE" "$INSTALL_UPDATE_TIMER" \ + "$VERSION_FILE" + do + local backup="$BACKUP_DIR/$(basename "$f")" + if [[ -f "$backup" ]]; then + cp -a "$backup" "$f" + fi + done + systemctl daemon-reload + systemctl start "$SERVICE_NAME" || true +} + +install_from_staging() { + local staging="$1" + # Required files: remsound-relay.py + remsound-relay.service. + if [[ ! -f "$staging/remsound-relay.py" ]]; then + log "ERROR: staging missing remsound-relay.py" + return 1 + fi + install -o root -g root -m 755 "$staging/remsound-relay.py" "$INSTALL_BIN" + python3 -m py_compile "$INSTALL_BIN" + if [[ -f "$staging/remsound-relay.service" ]]; then + install -o root -g root -m 644 "$staging/remsound-relay.service" "$INSTALL_SERVICE" + fi + if [[ -f "$staging/remsound-relay-update.sh" ]]; then + install -o root -g root -m 755 "$staging/remsound-relay-update.sh" "$INSTALL_UPDATER" + fi + if [[ -f "$staging/remsound-relay-update.service" ]]; then + install -o root -g root -m 644 "$staging/remsound-relay-update.service" "$INSTALL_UPDATE_SERVICE" + fi + if [[ -f "$staging/remsound-relay-update.timer" ]]; then + install -o root -g root -m 644 "$staging/remsound-relay-update.timer" "$INSTALL_UPDATE_TIMER" + fi + return 0 +} + +# -------- main flow ---------------------------------------------------------- + +main() { + require_root + ensure_dirs + + log "update check starting (repo=$REPO prefix=$TAG_PREFIX)" + + local current latest_tag asset_url asset_name + current="$(read_current_version)" + log "currently installed: $current" + + local release_info + if ! release_info="$(get_latest_release)"; then + log "no upgrade attempted (could not query releases or no eligible release)" + return 0 + fi + latest_tag="$(printf '%s\n' "$release_info" | sed -n '1p')" + asset_url="$(printf '%s\n' "$release_info" | sed -n '2p')" + asset_name="$(printf '%s\n' "$release_info" | sed -n '3p')" + log "latest available: $latest_tag asset=$asset_name" + + if ! tag_newer_than "$latest_tag" "$current"; then + log "up to date (installed $current >= available $latest_tag)" + return 0 + fi + + log "newer release found: $latest_tag -> upgrading from $current" + + # Working area in /tmp. Use the script-global $WORK_DIR (not a function + # local) so the EXIT trap can still see the variable after main returns. + # The trap is also script-global, registered just below. + WORK_DIR="$(mktemp -d -t remsound-relay-update.XXXXXXXX)" + + local tarball="$WORK_DIR/$asset_name" + log "downloading $asset_url" + if ! curl --fail --silent --show-error --max-time 120 \ + --location -o "$tarball" "$asset_url"; then + log "ERROR: download failed" + return 1 + fi + + log "extracting $asset_name" + if ! tar -xzf "$tarball" -C "$WORK_DIR"; then + log "ERROR: tarball extraction failed" + return 1 + fi + # Find the staging root — first directory inside the work dir. + local staging + staging="$(find "$WORK_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + if [[ -z "$staging" ]]; then + log "ERROR: tarball did not contain a top-level folder" + return 1 + fi + log "staging at $staging" + + snapshot_backup + + log "stopping $SERVICE_NAME" + systemctl stop "$SERVICE_NAME" || true + + if ! install_from_staging "$staging"; then + log "ERROR: install step failed — rolling back" + restore_backup + return 1 + fi + + # Persist the new version BEFORE starting, so a crash after start still + # leaves the version file consistent with what's on disk. + printf '%s\n' "$latest_tag" > "$VERSION_FILE" + + log "reloading systemd + starting $SERVICE_NAME" + systemctl daemon-reload + systemctl start "$SERVICE_NAME" || true + + sleep "$HEALTH_WAIT_SECONDS" + + if systemctl is-active --quiet "$SERVICE_NAME"; then + log "post-install check: $SERVICE_NAME is active — upgrade to $latest_tag complete" + return 0 + fi + + log "post-install check FAILED: $SERVICE_NAME not active — rolling back" + restore_backup + if systemctl is-active --quiet "$SERVICE_NAME"; then + log "rollback succeeded — back on $current" + else + log "ERROR: rollback also did not restore service — manual intervention required" + fi + return 1 +} + +main "$@" diff --git a/pi server/remsound-relay-update.timer b/pi server/remsound-relay-update.timer new file mode 100644 index 0000000..f8ef231 --- /dev/null +++ b/pi server/remsound-relay-update.timer @@ -0,0 +1,16 @@ +[Unit] +Description=Periodic update check for remsound-relay + +[Timer] +# Fires 2 minutes after boot, then every hour, with a 10-minute random +# jitter so multiple relays don't all hit the GitHub API at the same +# instant. Persistent=true means missed runs (host was off) catch up on +# the next boot rather than silently skipping. +OnBootSec=2min +OnUnitActiveSec=1h +RandomizedDelaySec=10min +Persistent=true +Unit=remsound-relay-update.service + +[Install] +WantedBy=timers.target diff --git a/pi server/remsound-relay.py b/pi server/remsound-relay.py new file mode 100644 index 0000000..796d0f9 --- /dev/null +++ b/pi server/remsound-relay.py @@ -0,0 +1,547 @@ +#!/usr/bin/env python3 +""" +RemSound UDP relay, dual-protocol. + +Listens on a single UDP port and handles two protocol versions concurrently: + +- v1 ("pairwise"): 12-byte header, two-slot reflector. First two distinct + UDP endpoints to send a valid RemSound v1 packet claim the slots; subsequent + v1 packets from one slot's endpoint are reflected to the other. Slots idle + for IDLE_TIMEOUT_SECONDS are eligible for replacement. This is the original + remsound-relay.py behaviour, preserved here unchanged so legacy clients keep + working against the new server. + +- v2 ("lobby"): 28-byte header with embedded CLIENT_ID (UUID). Up to + REMSOUND_MAX_CLIENTS instances (default 10) form a single lobby. Each + incoming packet is forwarded unmodified to every OTHER registered client. + Identity is the CLIENT_ID, not the network endpoint — NAT rebinds and + same-NAT-multiple-clients are no longer special cases. Periodic LobbyRoster + packets keep clients informed of the current membership. + +The two protocols share state only via the listening socket and the stats +counters. They never interact otherwise: a v1 client and a v2 client cannot +hear each other in this release (deliberate — see the design doc). + +Owner: Pi thread. Spec: D:\\Dropbox\\proj\\pi\\remsound server update.md. +""" + +from __future__ import annotations + +import argparse +import logging +import logging.handlers +import os +import select +import signal +import socket +import struct +import sys +import time +import uuid +from dataclasses import dataclass, field +from typing import Optional + +LISTEN_HOST = "0.0.0.0" +DEFAULT_PORT = 47830 +RECV_BUFFER_BYTES = 2048 +IDLE_TIMEOUT_SECONDS = 60 +STATS_INTERVAL_SECONDS = 60 +ROSTER_HEARTBEAT_SECONDS = 1.0 # v2 only — periodic roster broadcast +SOCKET_POLL_TIMEOUT_SECONDS = 1.0 +DEFAULT_LOG_PATH = "/var/log/remsound-relay.log" +DEFAULT_MAX_CLIENTS = 10 +LOBBY_NAME_BYTES = 32 # bytes reserved for a display name on the wire + +# Wire format constants. +MAGIC = b"RMND" +V1_VERSION = 1 +V2_VERSION = 2 +V1_HEADER_LEN = 12 +V2_HEADER_LEN = 28 +V2_CLIENT_ID_OFFSET = 12 +V2_CLIENT_ID_LEN = 16 + +# Packet types (v1 + v2 shared range; v2-only types are 6+). +TYPE_FORMAT = 1 +TYPE_AUDIO = 2 +TYPE_KEEPALIVE = 3 +TYPE_HEARTBEAT = 4 +TYPE_CONTROL = 5 +TYPE_LOBBY_HELLO = 6 +TYPE_LOBBY_ROSTER = 7 +TYPE_LOBBY_FULL = 8 +TYPE_LOBBY_BYE = 9 +V2_FORWARDABLE_TYPES = { + TYPE_FORMAT, TYPE_AUDIO, TYPE_KEEPALIVE, TYPE_HEARTBEAT, TYPE_CONTROL, +} + +# A zero UUID identifies the server in outbound v2 packets that we originate +# (LobbyRoster, LobbyFull, LobbyBye-from-server). Clients can recognise this +# as "from server" rather than from another peer. +SERVER_CLIENT_ID_BYTES = b"\x00" * V2_CLIENT_ID_LEN + + +@dataclass +class PeerSlot: + """v1 protocol — one of (up to) two peer endpoints in a pair.""" + addr: tuple[str, int] + last_seen: float + rx_packets: int = 0 + tx_packets: int = 0 + + +@dataclass +class ClientEntry: + """v2 protocol — one client in the lobby, keyed by CLIENT_ID.""" + addr: tuple[str, int] + display_name: str + last_seen: float + rx_packets: int = 0 + tx_packets: int = 0 + + +@dataclass +class RelayStats: + forwarded: int = 0 + dropped_unpaired: int = 0 # v1: third endpoint while pair active + dropped_lobby_full: int = 0 # v2: 11th client when at cap + rejected_bad_header: int = 0 + pair_changes: int = 0 # v1 slot joins/leaves/replacements + lobby_changes: int = 0 # v2 joins/leaves/expiries + + +def setup_logger(log_path: str) -> logging.Logger: + logger = logging.getLogger("remsound-relay") + logger.setLevel(logging.INFO) + fmt = logging.Formatter( + fmt="%(asctime)s level=%(levelname)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + try: + fh = logging.handlers.WatchedFileHandler(log_path, encoding="utf-8") + fh.setFormatter(fmt) + logger.addHandler(fh) + except OSError as e: + sys.stderr.write(f"remsound-relay: could not open {log_path}: {e}\n") + sh = logging.StreamHandler(sys.stderr) + sh.setFormatter(fmt) + logger.addHandler(sh) + return logger + + +def parse_header_v1(data: bytes) -> Optional[tuple[int, int, int]]: + """Validate a v1 header. Returns (type, stream_id, sequence) or None.""" + if len(data) < V1_HEADER_LEN: + return None + pkt_type = data[5] + stream_id = struct.unpack_from(" Optional[tuple[int, int, int, bytes]]: + """Validate a v2 header. Returns (type, stream_id, sequence, client_id_bytes) or None.""" + if len(data) < V2_HEADER_LEN: + return None + pkt_type = data[5] + stream_id = struct.unpack_from(" str: + return f"{addr[0]}:{addr[1]}" + + +def _decode_lobby_name(raw: bytes) -> str: + """Decode the 32-byte null-padded UTF-8 display-name field. Tolerant of garbage.""" + end = raw.find(b"\x00") + if end >= 0: + raw = raw[:end] + try: + return raw.decode("utf-8", errors="replace").strip() + except Exception: + return "" + + +def _encode_lobby_name(name: str) -> bytes: + """Encode a display name into LOBBY_NAME_BYTES, null-padded.""" + encoded = (name or "").encode("utf-8", errors="replace")[:LOBBY_NAME_BYTES] + return encoded + b"\x00" * (LOBBY_NAME_BYTES - len(encoded)) + + +class Relay: + """Dispatcher that owns both the v1 pair state and the v2 lobby state.""" + + def __init__(self, sock: socket.socket, log: logging.Logger, max_clients: int): + self.sock = sock + self.log = log + self.max_clients = max_clients + # v1 state + self.v1_peers: list[PeerSlot] = [] + # v2 state + self.v2_clients: dict[uuid.UUID, ClientEntry] = {} + self.v2_roster_dirty = False # set when membership changes + self.v2_last_roster_broadcast = 0.0 + # shared + self.stats = RelayStats() + self.last_stats_log = time.monotonic() + + # ------- v1 (pairwise) ------------------------------------------------- + + def _v1_find_slot(self, addr: tuple[str, int]) -> Optional[int]: + for i, p in enumerate(self.v1_peers): + if p.addr == addr: + return i + return None + + def _v1_expire_idle(self, now: float) -> None: + if not self.v1_peers: + return + kept: list[PeerSlot] = [] + dropped: list[tuple[str, int]] = [] + for p in self.v1_peers: + if (now - p.last_seen) <= IDLE_TIMEOUT_SECONDS: + kept.append(p) + else: + dropped.append(p.addr) + if dropped: + self.v1_peers = kept + for addr in dropped: + self.log.info( + "event=peer_dropped reason=idle addr=%s remaining=%d", + _fmt_addr(addr), len(self.v1_peers), + ) + self.stats.pair_changes += 1 + + def _v1_admit_or_replace(self, addr: tuple[str, int], now: float) -> int: + if len(self.v1_peers) < 2: + self.v1_peers.append(PeerSlot(addr=addr, last_seen=now)) + self.log.info( + "event=peer_joined addr=%s slots_filled=%d", + _fmt_addr(addr), len(self.v1_peers), + ) + self.stats.pair_changes += 1 + if len(self.v1_peers) == 2: + self.log.info( + "event=peer_paired a=%s b=%s", + _fmt_addr(self.v1_peers[0].addr), + _fmt_addr(self.v1_peers[1].addr), + ) + return len(self.v1_peers) - 1 + oldest = 0 if self.v1_peers[0].last_seen <= self.v1_peers[1].last_seen else 1 + if (now - self.v1_peers[oldest].last_seen) > IDLE_TIMEOUT_SECONDS: + old_addr = self.v1_peers[oldest].addr + self.v1_peers[oldest] = PeerSlot(addr=addr, last_seen=now) + self.log.info( + "event=peer_replaced old=%s new=%s", + _fmt_addr(old_addr), _fmt_addr(addr), + ) + self.stats.pair_changes += 1 + return oldest + return -1 + + def _handle_v1(self, data: bytes, addr: tuple[str, int]) -> None: + if parse_header_v1(data) is None: + self.stats.rejected_bad_header += 1 + return + now = time.monotonic() + idx = self._v1_find_slot(addr) + if idx is None: + self._v1_expire_idle(now) + idx = self._v1_admit_or_replace(addr, now) + if idx < 0: + self.stats.dropped_unpaired += 1 + return + peer = self.v1_peers[idx] + peer.last_seen = now + peer.rx_packets += 1 + if len(self.v1_peers) == 2: + other = self.v1_peers[1 - idx] + try: + self.sock.sendto(data, other.addr) + other.tx_packets += 1 + self.stats.forwarded += 1 + except OSError as e: + self.log.warning( + "event=send_failed proto=v1 to=%s err=%s", + _fmt_addr(other.addr), e, + ) + else: + self.stats.dropped_unpaired += 1 + + # ------- v2 (lobby) ---------------------------------------------------- + + def _v2_build_roster_packet(self) -> bytes: + """Build a LobbyRoster packet with the current membership.""" + # Use a separate per-build sequence — clients can ignore it; we use 0. + header = bytearray(V2_HEADER_LEN) + header[0:4] = MAGIC + header[4] = V2_VERSION + header[5] = TYPE_LOBBY_ROSTER + struct.pack_into(" None: + if not self.v2_clients: + self.v2_roster_dirty = False + self.v2_last_roster_broadcast = time.monotonic() + return + packet = self._v2_build_roster_packet() + for entry in self.v2_clients.values(): + try: + self.sock.sendto(packet, entry.addr) + except OSError as e: + self.log.warning( + "event=send_failed proto=v2 reason=roster to=%s err=%s", + _fmt_addr(entry.addr), e, + ) + self.v2_roster_dirty = False + self.v2_last_roster_broadcast = time.monotonic() + + def _v2_send_lobby_full(self, attempted_client_id: uuid.UUID, addr: tuple[str, int]) -> None: + """Send a LobbyFull packet back to an over-cap client and log it.""" + header = bytearray(V2_HEADER_LEN) + header[0:4] = MAGIC + header[4] = V2_VERSION + header[5] = TYPE_LOBBY_FULL + struct.pack_into(" None: + if not self.v2_clients: + return + expired: list[uuid.UUID] = [] + for cid, entry in self.v2_clients.items(): + if (now - entry.last_seen) > IDLE_TIMEOUT_SECONDS: + expired.append(cid) + for cid in expired: + entry = self.v2_clients.pop(cid) + self.log.info( + "event=client_idle_expired client_id=%s addr=%s", + cid, _fmt_addr(entry.addr), + ) + self.stats.lobby_changes += 1 + self.v2_roster_dirty = True + + def _handle_v2(self, data: bytes, addr: tuple[str, int]) -> None: + parsed = parse_header_v2(data) + if parsed is None: + self.stats.rejected_bad_header += 1 + return + pkt_type, _stream_id, _sequence, cid_bytes = parsed + try: + client_id = uuid.UUID(bytes=cid_bytes) + except ValueError: + self.stats.rejected_bad_header += 1 + return + now = time.monotonic() + entry = self.v2_clients.get(client_id) + if entry is None: + # Admit attempt. + if len(self.v2_clients) >= self.max_clients: + self._v2_send_lobby_full(client_id, addr) + return + entry = ClientEntry(addr=addr, display_name="", last_seen=now) + self.v2_clients[client_id] = entry + self.log.info( + "event=client_joined client_id=%s addr=%s count=%d", + client_id, _fmt_addr(addr), len(self.v2_clients), + ) + self.stats.lobby_changes += 1 + self.v2_roster_dirty = True + else: + # Refresh endpoint (handles NAT rebind) and last-seen. + if entry.addr != addr: + self.log.info( + "event=client_endpoint_update client_id=%s old=%s new=%s", + client_id, _fmt_addr(entry.addr), _fmt_addr(addr), + ) + entry.addr = addr + entry.last_seen = now + entry.rx_packets += 1 + + # Type-specific handling. + if pkt_type == TYPE_LOBBY_HELLO: + payload = data[V2_HEADER_LEN:V2_HEADER_LEN + LOBBY_NAME_BYTES] + new_name = _decode_lobby_name(payload) + if new_name != entry.display_name: + entry.display_name = new_name + self.log.info( + "event=client_named client_id=%s name=%r", client_id, new_name, + ) + self.v2_roster_dirty = True + return + if pkt_type == TYPE_LOBBY_BYE: + self.v2_clients.pop(client_id, None) + self.log.info( + "event=client_left client_id=%s addr=%s reason=bye", + client_id, _fmt_addr(addr), + ) + self.stats.lobby_changes += 1 + self.v2_roster_dirty = True + return + if pkt_type not in V2_FORWARDABLE_TYPES: + # Unknown / server-originated type from a client. Ignore quietly. + return + + # Fan-out forwarding to every OTHER client. + for other_id, other in self.v2_clients.items(): + if other_id == client_id: + continue + try: + self.sock.sendto(data, other.addr) + other.tx_packets += 1 + self.stats.forwarded += 1 + except OSError as e: + self.log.warning( + "event=send_failed proto=v2 to=%s err=%s", + _fmt_addr(other.addr), e, + ) + + # ------- shared -------------------------------------------------------- + + def handle_packet(self, data: bytes, addr: tuple[str, int]) -> None: + if len(data) < 6 or data[0:4] != MAGIC: + self.stats.rejected_bad_header += 1 + return + version = data[4] + if version == V1_VERSION: + self._handle_v1(data, addr) + elif version == V2_VERSION: + self._handle_v2(data, addr) + else: + self.stats.rejected_bad_header += 1 + + def tick(self, now: float) -> None: + """Periodic housekeeping: idle expiry + roster broadcast.""" + self._v1_expire_idle(now) + self._v2_expire_idle(now) + if self.v2_clients and ( + self.v2_roster_dirty + or (now - self.v2_last_roster_broadcast) >= ROSTER_HEARTBEAT_SECONDS + ): + self._v2_broadcast_roster() + + def maybe_log_stats(self, now: float) -> None: + if (now - self.last_stats_log) < STATS_INTERVAL_SECONDS: + return + self.last_stats_log = now + s = self.stats + v1_summary = ", ".join( + f"{_fmt_addr(p.addr)}(rx={p.rx_packets},tx={p.tx_packets})" + for p in self.v1_peers + ) or "none" + v2_summary = ", ".join( + f"{cid}@{_fmt_addr(e.addr)}(rx={e.rx_packets},tx={e.tx_packets})" + for cid, e in self.v2_clients.items() + ) or "none" + self.log.info( + "event=stats forwarded=%d dropped_unpaired=%d dropped_lobby_full=%d " + "rejected_bad_header=%d pair_changes=%d lobby_changes=%d " + "client_count=%d v1_peers=[%s] v2_clients=[%s]", + s.forwarded, s.dropped_unpaired, s.dropped_lobby_full, + s.rejected_bad_header, s.pair_changes, s.lobby_changes, + len(self.v2_clients), v1_summary, v2_summary, + ) + self.stats = RelayStats() + for p in self.v1_peers: + p.rx_packets = 0 + p.tx_packets = 0 + for e in self.v2_clients.values(): + e.rx_packets = 0 + e.tx_packets = 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="RemSound UDP relay (dual-protocol v1+v2)") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, + help=f"UDP port to listen on (default {DEFAULT_PORT})") + parser.add_argument("--host", default=LISTEN_HOST, + help=f"Bind address (default {LISTEN_HOST})") + parser.add_argument("--log-path", default=DEFAULT_LOG_PATH, + help=f"Log file path (default {DEFAULT_LOG_PATH})") + parser.add_argument( + "--max-clients", type=int, + default=int(os.environ.get("REMSOUND_MAX_CLIENTS", str(DEFAULT_MAX_CLIENTS))), + help=f"v2 lobby capacity (default {DEFAULT_MAX_CLIENTS}, " + "overridable via REMSOUND_MAX_CLIENTS env var)", + ) + args = parser.parse_args() + if args.max_clients < 2: + sys.stderr.write("remsound-relay: --max-clients must be >= 2\n") + return 2 + + log = setup_logger(args.log_path) + log.info( + "event=startup version_supported=v1,v2 listen=%s:%d max_clients=%d", + args.host, args.port, args.max_clients, + ) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((args.host, args.port)) + except OSError as e: + log.error("event=bind_failed err=%s", e) + return 1 + + relay = Relay(sock, log, args.max_clients) + stop_flag = {"stop": False} + + def _stop_signal(_signum, _frame): + stop_flag["stop"] = True + + signal.signal(signal.SIGTERM, _stop_signal) + signal.signal(signal.SIGINT, _stop_signal) + + try: + while not stop_flag["stop"]: + try: + ready, _, _ = select.select([sock], [], [], SOCKET_POLL_TIMEOUT_SECONDS) + except InterruptedError: + continue + now = time.monotonic() + if ready: + try: + data, addr = sock.recvfrom(RECV_BUFFER_BYTES) + except OSError as e: + log.warning("event=recv_failed err=%s", e) + continue + relay.handle_packet(data, addr) + relay.tick(now) + relay.maybe_log_stats(now) + finally: + log.info("event=shutdown") + sock.close() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pi server/remsound-relay.service b/pi server/remsound-relay.service new file mode 100644 index 0000000..fe7f060 --- /dev/null +++ b/pi server/remsound-relay.service @@ -0,0 +1,14 @@ +[Unit] +Description=RemSound UDP relay +Documentation=file:///usr/local/sbin/remsound-relay.py +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /usr/local/sbin/remsound-relay.py +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/pi server/smoke-test.sh b/pi server/smoke-test.sh new file mode 100644 index 0000000..b47fd40 --- /dev/null +++ b/pi server/smoke-test.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# smoke-test.sh — Quick health check for the RemSound relay + auto-updater. +# +# Run after install. Confirms the relay service is running, listening on +# UDP 47830, accepts both a v1 and a v2 valid RemSound header, and that +# the auto-updater scaffolding is in place. + +set -u + +GREEN=$'\033[0;32m' +RED=$'\033[0;31m' +YELLOW=$'\033[0;33m' +RESET=$'\033[0m' + +ok() { printf "%s[ok]%s %s\n" "$GREEN" "$RESET" "$*"; } +fail() { printf "%s[FAIL]%s %s\n" "$RED" "$RESET" "$*"; } +warn() { printf "%s[warn]%s %s\n" "$YELLOW" "$RESET" "$*"; } + +failures=0 + +# 1. Relay service active? +if systemctl is-active --quiet remsound-relay.service; then + ok "remsound-relay.service is active" +else + fail "remsound-relay.service is not active. Try: sudo systemctl status remsound-relay" + failures=$((failures + 1)) +fi + +# 2. Listening on UDP 47830? +if command -v ss >/dev/null 2>&1; then + if ss -lun 2>/dev/null | grep -q ':47830'; then + ok "listening on UDP 47830" + else + fail "no UDP 47830 listener visible to ss" + failures=$((failures + 1)) + fi +else + warn "ss not installed, skipping listener check" +fi + +# 3. Send synthetic valid v1 + v2 RemSound headers, confirm they're accepted. +if command -v python3 >/dev/null 2>&1; then + python3 - <<'PY' +import socket, struct, uuid +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + +# v1: magic 'RMND' (LE 'RMND'), version 1, type 4 (Heartbeat), stream 1, seq 1. +v1 = bytes([0x52, 0x4D, 0x4E, 0x44, 1, 4, 1, 0, 1, 0, 0, 0]) +s.sendto(v1, ("127.0.0.1", 47830)) + +# v2: magic 'RMND', version 2, type 4 (Heartbeat), stream 1, seq 1, random UUID. +cid = uuid.uuid4().bytes +v2 = bytes([0x52, 0x4D, 0x4E, 0x44, 2, 4, 1, 0, 1, 0, 0, 0]) + cid +s.sendto(v2, ("127.0.0.1", 47830)) + +s.close() +PY + ok "sent synthetic v1 + v2 RemSound headers to 127.0.0.1:47830" +else + warn "python3 not installed, skipping header send" +fi + +# 4. Look for the expected log events for both protocols. +sleep 1 +read_log() { + if [[ -r /var/log/remsound-relay.log ]]; then + tail -40 /var/log/remsound-relay.log 2>/dev/null + elif sudo -n test -r /var/log/remsound-relay.log 2>/dev/null; then + sudo tail -40 /var/log/remsound-relay.log 2>/dev/null + fi +} +log_lines="$(read_log)" +if [[ -n "$log_lines" ]]; then + if printf '%s\n' "$log_lines" | grep -q "event=peer_joined.*127.0.0.1"; then + ok "v1 path: relay logged a peer_joined event for 127.0.0.1" + else + warn "v1 path: no peer_joined event for 127.0.0.1 in last 40 log lines" + fi + if printf '%s\n' "$log_lines" | grep -q "event=client_joined.*127.0.0.1"; then + ok "v2 path: relay logged a client_joined event for 127.0.0.1" + else + warn "v2 path: no client_joined event for 127.0.0.1 in last 40 log lines" + fi +else + warn "cannot read /var/log/remsound-relay.log (try: sudo $0)" +fi + +# 5. Auto-updater scaffolding in place? +if [[ -x /usr/local/sbin/remsound-relay-update.sh ]]; then + ok "auto-updater script at /usr/local/sbin/remsound-relay-update.sh" +else + fail "missing /usr/local/sbin/remsound-relay-update.sh" + failures=$((failures + 1)) +fi +if systemctl is-active --quiet remsound-relay-update.timer; then + ok "remsound-relay-update.timer is active" +else + fail "remsound-relay-update.timer is not active. Try: sudo systemctl status remsound-relay-update.timer" + failures=$((failures + 1)) +fi +if [[ -r /etc/remsound-relay/version ]]; then + installed_tag="$(tr -d '[:space:]' < /etc/remsound-relay/version)" + if [[ "$installed_tag" =~ ^server-v[0-9]+\.[0-9]+ ]]; then + ok "installed version: $installed_tag" + else + warn "version file present but unexpected format: $installed_tag" + fi +elif sudo -n test -r /etc/remsound-relay/version 2>/dev/null; then + installed_tag="$(sudo tr -d '[:space:]' < /etc/remsound-relay/version)" + ok "installed version (via sudo): $installed_tag" +else + fail "cannot read /etc/remsound-relay/version" + failures=$((failures + 1)) +fi + +echo +if (( failures == 0 )); then + echo "${GREEN}All checks passed.${RESET}" + exit 0 +else + echo "${RED}${failures} check(s) failed.${RESET}" + exit 1 +fi diff --git a/pi server/uninstall.sh b/pi server/uninstall.sh new file mode 100644 index 0000000..668a773 --- /dev/null +++ b/pi server/uninstall.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# uninstall.sh — Remove the RemSound UDP relay and its auto-updater cleanly. +# +# Run with sudo: +# sudo ./uninstall.sh +# +# Stops and disables both the relay and the updater timer, removes their +# scripts and unit files, and removes the /etc/remsound-relay/ state dir +# (version file + rollback backups). Leaves log files in place — rename or +# delete them yourself if you don't want them kept. Does NOT touch your +# router port-forward — that's separate. + +set -euo pipefail + +if [[ $EUID -ne 0 ]]; then + echo "This uninstaller must run as root. Try: sudo ./uninstall.sh" >&2 + exit 1 +fi + +# Stop the updater timer first so it can't fire mid-uninstall. +if systemctl list-unit-files remsound-relay-update.timer >/dev/null 2>&1; then + echo "Stopping remsound-relay-update.timer ..." + systemctl stop remsound-relay-update.timer 2>/dev/null || true + systemctl disable remsound-relay-update.timer 2>/dev/null || true +fi + +if systemctl list-unit-files remsound-relay-update.service >/dev/null 2>&1; then + systemctl stop remsound-relay-update.service 2>/dev/null || true +fi + +if systemctl list-unit-files remsound-relay.service >/dev/null 2>&1; then + echo "Stopping remsound-relay.service ..." + systemctl stop remsound-relay.service 2>/dev/null || true + echo "Disabling remsound-relay.service ..." + systemctl disable remsound-relay.service 2>/dev/null || true +fi + +for f in \ + /etc/systemd/system/remsound-relay.service \ + /etc/systemd/system/remsound-relay-update.service \ + /etc/systemd/system/remsound-relay-update.timer \ + /usr/local/sbin/remsound-relay.py \ + /usr/local/sbin/remsound-relay-update.sh +do + if [[ -f "$f" ]]; then + echo "Removing $f ..." + rm -f "$f" + fi +done + +if [[ -d /etc/remsound-relay ]]; then + echo "Removing /etc/remsound-relay/ (version stamp + backup) ..." + rm -rf /etc/remsound-relay +fi + +systemctl daemon-reload + +echo +echo "Uninstall complete." +echo "Note: log files left in place — delete them yourself if you don't want them:" +echo " /var/log/remsound-relay.log" +echo " /var/log/remsound-relay-update.log" +echo "Note: any router port-forward you added for UDP 47830 is unchanged — remove that yourself if you no longer need it." diff --git a/readme.html b/readme.html index bf7b9d9..c7c2e72 100644 --- a/readme.html +++ b/readme.html @@ -35,7 +35,7 @@ ul, ol { padding-left: 1.4em; }
  • Connectivity tab
  • Audio inputs and outputs tab
  • Audio profile tab
  • -
  • Pan and EQ tab
  • +
  • Volume, pan and EQ for peers tab
  • ASIO and WASAPI
  • Peers — finding and connecting
  • How the network works (LAN, WAN, Tailscale)
  • @@ -219,7 +219,7 @@ ul, ol { padding-left: 1.4em; }
    1. A menu bar at the top with four menus — File, Record, Options and Help. See Menus.
    2. -
    3. A row of tabs with three tabs — Connectivity, Audio inputs and outputs, Audio profile. A fourth tab, Pan and EQ, appears just before Audio profile if you switch it on in Preferences (it's off by default — see Pan and EQ tab). Each tab has its own Alt+letter shortcuts that only work when that tab is the one showing — so the same letter can do different things on different tabs without clashing.
    4. +
    5. A row of tabs with three tabs — Connectivity, Audio inputs and outputs, Audio profile. A fourth tab, Volume, pan and EQ for peers, appears just before Audio profile and is shown by default (you can hide it in Preferences — see Volume, pan and EQ for peers tab). Each tab has its own Alt+letter shortcuts that only work when that tab is the one showing — so the same letter can do different things on different tabs without clashing.
    6. A status line at the bottom that updates once a second with how long you've been connected, how many peers you have, whether sound is flowing, connection health, and RemSound's own CPU and memory usage.
    @@ -227,7 +227,7 @@ ul, ol { padding-left: 1.4em; } TabWhat it's for ConnectivityConnected, discovered and remembered peers. Adding a peer by address. A connection status read-out. Audio inputs and outputsThe ASIO driver picker (when an ASIO driver is installed), the Receive audio and Send my audio checkboxes, and all the device lists. Choosing a real driver in the picker brings up the ASIO device lists alongside the ordinary Windows ones; choosing (none) hides them. -Pan and EQ (optional)Shape each connected peer's sound on its own — their volume, pan (left/right) and EQ. Hidden by default; tick “Show the Pan and EQ tab” on the General tab of Preferences to show it. See Pan and EQ tab. +Volume, pan and EQ for peers (optional)Shape each connected peer's sound on its own — their volume, pan (left/right) and EQ. Shown by default; untick “Show the volume, pan and EQ for peers tab” on the General tab of Preferences to hide it. See Volume, pan and EQ for peers tab. Audio profileCodec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into an Audio send parameters group and an Audio receive parameters group. @@ -311,7 +311,7 @@ ul, ol { padding-left: 1.4em; } Profile passwords…Alt+O, WLists every profile alongside its password, so you can view or change any of them in one place. Reset the default audio device promptAlt+O, RBrings back the “use only the default audio device?” question if you previously ticked “Don't ask me this again” on it. See Following the Windows default audio device. Enable / Disable Realtek ASIO—Only shown if a Realtek ASIO driver is installed. Lets you reverse the choice RemSound offered about disabling that driver (Realtek's generic ASIO driver tends to grab the wrong device and clash with your screen reader). -Preferences…Ctrl+P, or Alt+O, POpens the Preferences dialog, organised into five tabs (move between them with Ctrl+Tab, or the arrow keys when the tab names have focus): General — the profiles folder, accept remote volume commands, UPnP router opening, and “Show the Pan and EQ tab” (off by default; turns on the optional Pan and EQ tab); Audio cues — the cue list and its sounds (see Audio cue sounds); Startup behaviour — start minimised / with Windows / with a specific profile; Update settings — the update checks and install options; and Logging — enable logs, write logs now, and the log-folder housekeeping (see Logs and diagnostics). Esc or the Close button dismisses it. +Preferences…Ctrl+P, or Alt+O, POpens the Preferences dialog, organised into five tabs (move between them with Ctrl+Tab, or the arrow keys when the tab names have focus): General — the profiles folder, accept remote volume commands, UPnP router opening, and “Show the volume, pan and EQ for peers tab” (on by default; hides the Volume, pan and EQ for peers tab if you untick it); Audio cues — the cue list and its sounds (see Audio cue sounds); Startup behaviour — start minimised / with Windows / with a specific profile; Update settings — the update checks and install options; and Logging — enable logs, write logs now, and the log-folder housekeeping (see Logs and diagnostics). Esc or the Close button dismisses it.

    Help menu

    @@ -349,7 +349,7 @@ ul, ol { padding-left: 1.4em; } Receive audioAlt+RThe master switch for receiving. When it's off, no sound plays out, no matter which output devices are ticked. WASAPI outputs for received soundAlt+3Tick which ordinary Windows outputs (speakers, headsets) should play the received sound. Ticking more than one means the received sound plays out of all of them at once. ASIO outputs for received soundAlt+1(Shown when an ASIO driver is chosen.) Tick which ASIO channel pairs should play the received sound. -Set volume for all received audioAlt+VA slider: the master volume for everything coming in. There is no separate volume per device or per person. +Master receive volumeAlt+VA slider: the master volume for everything coming in. There is no separate volume per device here; per-person volume lives on the Volume, pan and EQ for peers tab. Send my audioAlt+SThe master switch for sending. WASAPI outputs to sendAlt+4Tick which Windows output devices to capture from — this captures whatever is currently playing on those speakers and sends it. WASAPI inputs to sendAlt+5Tick which Windows input devices to capture (microphones, line-ins). @@ -446,31 +446,44 @@ ul, ol { padding-left: 1.4em; }

    Most people only need to pick a codec and a smoothness level, and leave everything else at its default.

    -

    9. Pan and EQ tab

    +

    9. Volume, pan and EQ for peers tab

    -

    This tab lets you shape the sound of each peer you're connected to, one at a time. You can set how loud that person is, lean them to the left or right, and change their tone with an equaliser. It's handy when you have several people connected at once and want to mix them — for a jam session you might put the drummer over to the left, turn someone down a little, or brighten someone up.

    +

    This tab lets you shape the sound of each peer you're connected to. You can set how loud that person is, lean them to the left or right, and change their tone with an equaliser. It's handy when you have several people connected at once and want to mix them — for a jam session you might put the drummer over to the left, turn someone down a little, or brighten someone up.

    -

    The Pan and EQ tab is optional and hidden by default. To turn it on, tick “Show the Pan and EQ tab” on the General tab of Preferences (Options → Preferences, or Ctrl+P). Once it's on, the tab appears just before the Audio profile tab.

    +

    The tab is shown by default. If you don't want it, untick “Show the volume, pan and EQ for peers tab” on the General tab of Preferences (Options → Preferences, or Ctrl+P). When it's on, the tab appears just before the Audio profile tab.

    -

    Shaping one peer at a time

    +

    Turning it on, and choosing who to shape

    -

    You always work on a single peer. First pick the person you want from the peer list, then use the controls below it — they all act on whoever you've selected. The tab, from top to bottom:

    +

    There's a single master switch, then a list of the people you're connected to. Tick a person in the list to shape them; whoever your cursor is on in the list is the person the controls below are editing. So you arrow to someone, tab down, and their volume, pan and EQ are right there. Unticking a person leaves their settings intact but passes their sound through untouched — a quick per-person bypass. The tab, from top to bottom:

    - - - + + - - - + + +
    ControlWhat it does
    Enable EQ for peers (checkbox)A master switch for the equaliser. When it's off, any EQ you've dialled in has no effect — but you can still set it up ready for when you turn it on.
    Enable pan for peers (checkbox)A master switch for panning. When it's off, any panning you've set has no effect, though you can still set it up in advance. Volume is different — it has no switch and is always in effect (but at 100% it changes nothing).
    Peer to shape (list)The peers you're currently connected to. Pick the one you want to work on. Everything below acts on the peer you select here.
    Enable volume, pan and EQ for all peers (checkbox)The one master switch. When it's off, everyone passes through untouched — but you can still set everything up ready for when you turn it on. There's also a global keyboard shortcut to flip this switch from anywhere (you set the key yourself in Keyboard shortcuts — it starts unset).
    Peers (checklist)The people you're currently connected to. Tick the ones you want shaped; untick to bypass a person while keeping their settings. Move your cursor onto a person to edit them — everything below acts on whoever the cursor is on.
    Volume (slider)An individual level for that one peer, from 0 to 100% (100% means unchanged). It sits on top of your main volume, so you can balance people against each other.
    Pan (slider)Leans the peer to the left or right. Centred by default. It keeps the peer's stereo sound — it never folds them down to mono.
    Set peer EQ to default (button)Puts that peer's EQ sliders — both the 3-band and the 12-band — back to flat. It leaves the pan and volume alone.
    EQ mode (picker)Two choices: 3 band basic EQ or 12 band advanced EQ. This chooses which set of band sliders you see below.
    EQ band slidersThe sliders for whichever EQ mode is chosen. Each one runs from −12 dB to +12 dB, with flat (no change) in the middle. The 3-band has Bass, Mids, Treble. The 12-band has 31 Hz, 63 Hz, 80 Hz, 125 Hz, 250 Hz, 500 Hz, 1 kHz, 2 kHz, 4 kHz, 6 kHz, 8 kHz and 16 kHz.
    Set peer EQ to default (button)Puts that peer's EQ back to flat — all three modes at once (the 3-band, the 12-band and the parametric bands). It leaves the pan and volume alone.
    EQ mode (picker)Three choices: 3 band simple EQ, 12 band advanced graphic EQ or 16 band parametric EQ. This chooses which EQ controls you see below.
    EQ controlsFor the two graphic modes, a set of sliders (see below). For the parametric mode, an Add band button and a list of your bands. Details follow.
    -

    The two EQ modes are kept separate. Switching between the 3-band and the 12-band keeps each one's own settings — nothing carries across from one to the other.

    +

    The three EQ modes

    -

    Everything here updates in real time — you hear the change as you move a control — and it adds no extra delay to the audio. All of it (the two switches, and each peer's volume, pan and EQ) is saved with the profile.

    +

    3 band simple EQ and 12 band advanced graphic EQ are graphic equalisers: a set of sliders at fixed frequencies, each running from −12 dB to +12 dB with flat (no change) in the middle. The 3-band has Bass, Mids, Treble. The 12-band has 31 Hz, 63 Hz, 80 Hz, 125 Hz, 250 Hz, 500 Hz, 1 kHz, 2 kHz, 4 kHz, 6 kHz, 8 kHz and 16 kHz. Each slider reads its level out in words, for example “plus 3 dB”, “minus 6 dB” or “flat”.

    + +

    16 band parametric EQ lets you place your own bands wherever you want them, up to sixteen. Instead of fixed sliders you build a list of bands:

    + +
      +
    • Tab past the mode picker to the Add band button and press it. A small dialog opens with three boxes: a start frequency, an end frequency and a gain in dB (from −12 to +12). Each box you can type into or spin with the arrow keys; they only accept sensible numbers. As you change the values you hear the band on that peer straight away. Press OK to add it, or Cancel / Escape to drop it.
    • +
    • Back on the tab, tab to the Bands list to hear your bands, one per row, each read out as its range and level — for example “200 Hz to 800 Hz, plus 3 dB”. The list is sorted low to high, so the bass bands are at the top and the treble at the bottom.
    • +
    • To remove a band, land on it and press Delete, or use the Delete band button. You can select several at once (hold Shift and arrow, or hold Ctrl and arrow then Space to pick out individual ones) and delete them together.
    • +
    + +

    Each parametric band is a boost or cut spread across the range between its start and end frequencies. A wide range affects a broad sweep of the sound; a narrow one is more surgical.

    + +

    The three modes are kept separate. Switching between them keeps each one's own settings — nothing carries across from one to another. Only the mode you've picked is the one you hear.

    + +

    Everything here updates in real time — you hear the change as you move a control — and it adds no extra delay to the audio. All of it (the master switch's setting is saved, and each peer's tick, volume, pan and EQ) is stored with the profile. The one exception is the global “toggle everything” keyboard shortcut, which is machine-wide rather than per-profile. There's also a small EQ response graph on the tab; it's purely a visual picture of the shape you've dialled in and plays no part in how you use the tab with a screen reader.

    10. ASIO and WASAPI

    @@ -825,19 +838,21 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L Alt+AFocus Artefact sound type -

    Pan and EQ tab

    +

    Volume, pan and EQ for peers tab

    -

    Only present when the Pan and EQ tab is switched on (tick “Show the Pan and EQ tab” on the General tab of Preferences). See Pan and EQ tab.

    +

    Present whenever the Volume, pan and EQ for peers tab is showing (it's shown by default; the toggle is “Show the volume, pan and EQ for peers tab” on the General tab of Preferences). See Volume, pan and EQ for peers tab.

    - - - + + + + +
    KeyAction
    Alt+EToggle Enable EQ for peers
    Alt+PToggle Enable pan for peers
    Alt+UFocus the peer to shape
    Alt+EToggle Enable volume, pan and EQ for all peers
    Alt+UFocus the Peers checklist
    Alt+LFocus the Volume slider
    Alt+NFocus the Pan slider
    Alt+QSet peer EQ to default
    Alt+MFocus the EQ mode picker
    Alt+AAdd band (parametric EQ mode only)
    Alt+BFocus the Bands list (parametric EQ mode only)
    Alt+DDelete band (parametric EQ mode only)

    File menu shortcuts (work from any tab)

    @@ -895,6 +910,7 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L Send Windows global volume down to peersThe same, but lowering.Unset Send Windows global mute toggle to peersTell every connected peer to toggle their Windows mute.Unset Speak the RemSound status informationRead the whole status line out loud through your screen reader — the connection time, how many peers you have, whether sound is flowing, and how healthy the link is — from anywhere, even with RemSound in the tray. Just for screen-reader users; see Hearing the status on demand below.Unset +Toggle volume, pan and EQ for all peersFlip the one master switch on the Volume, pan and EQ for peers tab from anywhere, so you can drop all your per-person shaping in and out without leaving the app you're in. See that tab for what the switch does.Unset

    You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.

    @@ -1211,7 +1227,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift - +
    ControlWhat it does
    Split recording into separate tracks (tickbox)Instead of one mixed file, a recording becomes a folder with one file per connected peer — each holding only that peer's sound — plus one file for your own send. Which of those files you get follows the Recording source choice below: Received only gives you the peer files; Both gives the peer files plus your own; Sent only gives just your own.
    Bypass pan and EQ when recording (tickbox)Records the raw sound — before any volume, pan or EQ you've set on the Pan and EQ tab — even though you still hear the shaped version. Left off (the default), the recording captures what you actually hear, including your shaping; and on a split recording each peer's own file carries that peer's own shaping.
    Bypass pan and EQ when recording (tickbox)Records the raw sound — before any volume, pan or EQ you've set on the Volume, pan and EQ for peers tab — even though you still hear the shaped version. Left off (the default), the recording captures what you actually hear, including your shaping; and on a split recording each peer's own file carries that peer's own shaping.
    diff --git a/server/__pycache__/remsound-relay.cpython-311.pyc b/server/__pycache__/remsound-relay.cpython-311.pyc new file mode 100644 index 0000000..293ce1a Binary files /dev/null and b/server/__pycache__/remsound-relay.cpython-311.pyc differ diff --git a/src/RemSound.App/AddBandDialog.cs b/src/RemSound.App/AddBandDialog.cs new file mode 100644 index 0000000..58bd885 --- /dev/null +++ b/src/RemSound.App/AddBandDialog.cs @@ -0,0 +1,157 @@ +using RemSound.Core; + +namespace RemSound.App; + +/// Modal dialog for adding one parametric EQ band. The user sets a start frequency, an end +/// frequency and a gain; all three are spin-or-type boxes that refuse non-numbers and clamp to range. +/// While the dialog is open it previews the in-progress band live (so the peer's sound changes as the +/// values move); OK keeps the band, Cancel/Escape drops it and reverts the preview. +internal sealed class AddBandDialog : Form +{ + private readonly NumericUpDown startFreq = new() + { + Minimum = (decimal)PeerEqBands.ParametricMinHz, + Maximum = (decimal)PeerEqBands.ParametricMaxHz, + Value = 200, + Increment = 10, + DecimalPlaces = 0, + Width = 120, + TextAlign = HorizontalAlignment.Right, + AccessibleName = "Start frequency in Hertz (Alt+S)", + }; + + private readonly NumericUpDown endFreq = new() + { + Minimum = (decimal)PeerEqBands.ParametricMinHz, + Maximum = (decimal)PeerEqBands.ParametricMaxHz, + Value = 2000, + Increment = 10, + DecimalPlaces = 0, + Width = 120, + TextAlign = HorizontalAlignment.Right, + AccessibleName = "End frequency in Hertz (Alt+E)", + }; + + private readonly NumericUpDown gainDb = new() + { + Minimum = -(decimal)PeerEqBands.MaxGainDb, + Maximum = (decimal)PeerEqBands.MaxGainDb, + Value = 3, + Increment = 1, + DecimalPlaces = 0, + Width = 120, + TextAlign = HorizontalAlignment.Right, + AccessibleName = "Gain in dB (Alt+G)", + }; + + private readonly Action? livePreview; + private bool accepted; + + /// The band the user built, valid only when returned OK. + public ParametricBand Result => new() + { + StartHz = (float)startFreq.Value, + EndHz = (float)endFreq.Value, + GainDb = (float)gainDb.Value, + }; + + /// Called with the in-progress band on every value change so the caller + /// can apply it to the peer in real time, and with null when the dialog is cancelled/closed so the + /// caller reverts to the saved shaping. + public AddBandDialog(Action? livePreview = null) + { + this.livePreview = livePreview; + + Text = "Add EQ band"; + StartPosition = FormStartPosition.CenterParent; + FormBorderStyle = FormBorderStyle.FixedDialog; + MinimizeBox = false; + MaximizeBox = false; + ShowInTaskbar = false; + ClientSize = new Size(340, 200); + + var grid = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(12), + ColumnCount = 2, + RowCount = 4, + }; + grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + + AddRow(grid, 0, "&Start frequency in Hz (Alt+S)", startFreq); + AddRow(grid, 1, "&End frequency in Hz (Alt+E)", endFreq); + AddRow(grid, 2, "&Gain in dB (Alt+G)", gainDb); + + var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.None }; + var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; + okButton.Click += (_, _) => TryAccept(); + + var buttonRow = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.RightToLeft, + AutoSize = true, + }; + buttonRow.Controls.Add(cancelButton); + buttonRow.Controls.Add(okButton); + grid.Controls.Add(buttonRow, 1, 3); + + Controls.Add(grid); + AcceptButton = okButton; + CancelButton = cancelButton; + + startFreq.ValueChanged += (_, _) => Preview(); + endFreq.ValueChanged += (_, _) => Preview(); + gainDb.ValueChanged += (_, _) => Preview(); + + Shown += (_, _) => { startFreq.Focus(); Preview(); }; + } + + // The mnemonic hint is embedded in each NumericUpDown's AccessibleName; the label carries the + // visible '&' so Alt+letter moves focus to the box (NumericUpDown has no '&' of its own). + private static void AddRow(TableLayoutPanel grid, int row, string labelText, NumericUpDown box) + { + var label = new Label { Text = labelText, AutoSize = true, Anchor = AnchorStyles.Left, Margin = new Padding(0, 6, 8, 0) }; + // A plain Label with '&' wires the mnemonic to the next control in tab order (the box). + grid.Controls.Add(label, 0, row); + grid.Controls.Add(box, 1, row); + } + + private ParametricBand Current() => new() + { + StartHz = (float)startFreq.Value, + EndHz = (float)endFreq.Value, + GainDb = (float)gainDb.Value, + }; + + private void Preview() => livePreview?.Invoke(Current()); + + private void TryAccept() + { + if (endFreq.Value <= startFreq.Value) + { + var page = new TaskDialogPage + { + Caption = "Add EQ band", + Heading = "End frequency must be higher than start", + Text = $"The end frequency ({endFreq.Value:0} Hz) must be higher than the start frequency ({startFreq.Value:0} Hz). Adjust one of them and try again.", + Icon = TaskDialogIcon.Warning, + Buttons = { TaskDialogButton.OK }, + }; + TaskDialog.ShowDialog(this, page); + endFreq.Focus(); + return; + } + accepted = true; + DialogResult = DialogResult.OK; + Close(); + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + base.OnFormClosing(e); + if (!accepted) livePreview?.Invoke(null); // revert the preview on cancel / close + } +} diff --git a/src/RemSound.App/EqCurveControl.cs b/src/RemSound.App/EqCurveControl.cs new file mode 100644 index 0000000..decdb02 --- /dev/null +++ b/src/RemSound.App/EqCurveControl.cs @@ -0,0 +1,126 @@ +using System.Drawing.Drawing2D; +using RemSound.Core; + +namespace RemSound.App; + +/// A purely-decorative frequency-response graph for the selected peer's EQ. Draws the dialled +/// EQ shape (all three modes feed the same curve) so a sighted onlooker sees the classic EQ picture. +/// It is NOT focusable and carries no accessible content — NVDA skips it entirely; every actual control +/// stays the sliders / list / buttons around it. RemSound has no sighted primary users, so this is a +/// low-cost nicety, not part of the interaction. +internal sealed class EqCurveControl : Panel +{ + private PeerShaping? shaping; + + // Log-frequency axis: 20 Hz .. 20 kHz. + private const double MinHz = 20.0; + private const double MaxHz = 20000.0; + private const float RangeDb = PeerEqBands.MaxGainDb; // curve spans -12..+12 dB + + public EqCurveControl() + { + TabStop = false; // never in the keyboard tab order + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer + | ControlStyles.ResizeRedraw | ControlStyles.UserPaint, true); + // No accessible name/role — leave it invisible to screen readers. + AccessibleName = ""; + } + + /// Point the curve at a peer's shaping (or null for flat) and repaint. + public void SetResponse(PeerShaping? s) + { + shaping = s; + Invalidate(); + } + + protected override void OnPaint(PaintEventArgs e) + { + var g = e.Graphics; + g.SmoothingMode = SmoothingMode.AntiAlias; + + int w = Math.Max(1, ClientSize.Width); + int h = Math.Max(1, ClientSize.Height); + + bool dark = BackColor.GetBrightness() < 0.5f; + using var bg = new SolidBrush(dark ? Color.FromArgb(30, 30, 30) : Color.FromArgb(245, 245, 245)); + g.FillRectangle(bg, 0, 0, w, h); + + // Grid: 0 dB centre line plus ±half-range guides. + using var gridPen = new Pen(dark ? Color.FromArgb(70, 70, 70) : Color.FromArgb(210, 210, 210)); + using var zeroPen = new Pen(dark ? Color.FromArgb(110, 110, 110) : Color.FromArgb(170, 170, 170)); + float midY = h / 2f; + g.DrawLine(gridPen, 0, h * 0.25f, w, h * 0.25f); + g.DrawLine(gridPen, 0, h * 0.75f, w, h * 0.75f); + g.DrawLine(zeroPen, 0, midY, w, midY); + + // Build the response polyline: one point per horizontal pixel. + var pts = new PointF[w]; + double logMin = Math.Log10(MinHz); + double logMax = Math.Log10(MaxHz); + for (int x = 0; x < w; x++) + { + double frac = w <= 1 ? 0 : (double)x / (w - 1); + double freq = Math.Pow(10, logMin + frac * (logMax - logMin)); + float db = ResponseDb(freq); + float clamped = Math.Clamp(db, -RangeDb, RangeDb); + // +db upward: y = mid - (db/range)*halfHeight + float y = midY - (clamped / RangeDb) * (h / 2f - 4f); + pts[x] = new PointF(x, y); + } + + using var curvePen = new Pen(dark ? Color.FromArgb(90, 200, 255) : Color.FromArgb(0, 120, 200), 2f); + if (w >= 2) g.DrawLines(curvePen, pts); + } + + /// Approximate combined EQ response in dB at one frequency. Cosmetic only — analytic bell / + /// shelf shapes summed in dB, close enough to the real biquad response for a picture. + private float ResponseDb(double freq) + { + var s = shaping; + if (s is null) return 0f; + + double total = 0; + switch (s.EqMode) + { + case PeerEqMode.Parametric16Band: + foreach (var band in s.ParametricBands) + { + if (band is null || MathF.Abs(band.GainDb) < 0.05f) continue; + PeerEqBands.ParametricToPeaking(band.StartHz, band.EndHz, out float centre, out float q); + total += Bell(freq, centre, q) * band.GainDb; + } + break; + + case PeerEqMode.Advanced10Band: + for (int i = 0; i < PeerEqBands.Advanced.Length; i++) + { + float gain = i < s.AdvancedBandsDb.Length ? s.AdvancedBandsDb[i] : 0f; + if (MathF.Abs(gain) < 0.05f) continue; + total += Bell(freq, PeerEqBands.Advanced[i].Freq, 1.4f) * gain; + } + break; + + default: // Simple3Band: bass low-shelf, mids peak, treble high-shelf + float bass = s.SimpleBandsDb.Length > 0 ? s.SimpleBandsDb[0] : 0f; + float mids = s.SimpleBandsDb.Length > 1 ? s.SimpleBandsDb[1] : 0f; + float treble = s.SimpleBandsDb.Length > 2 ? s.SimpleBandsDb[2] : 0f; + total += LowShelf(freq, PeerEqBands.Simple[0].Freq) * bass; + total += Bell(freq, PeerEqBands.Simple[1].Freq, 0.9f) * mids; + total += HighShelf(freq, PeerEqBands.Simple[2].Freq) * treble; + break; + } + return (float)total; + } + + // Gaussian bell in log-frequency, peak 1.0 at f0. Width from Q (higher Q = narrower). + private static double Bell(double f, double f0, double q) + { + double sigmaOct = 1.0 / (2.0 * Math.Max(0.1, q)); + double sigmaLn = sigmaOct * Math.Log(2.0); + double x = Math.Log(f / f0) / Math.Max(1e-6, sigmaLn); + return Math.Exp(-0.5 * x * x); + } + + private static double LowShelf(double f, double f0) => 1.0 / (1.0 + Math.Pow(f / f0, 2.0)); + private static double HighShelf(double f, double f0) => 1.0 / (1.0 + Math.Pow(f0 / f, 2.0)); +} diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 2f835d6..9063d71 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -152,16 +152,23 @@ public sealed class MainForm : Form private readonly TabPage audioIOTabPage = new("Audio inputs and outputs"); private readonly TabPage audioProfileTabPage = new("Audio profile"); - // === Pan and EQ tab (per-peer shaping) — shown only when AppConfig.ShowPanEqTab is on. === - private readonly TabPage panEqTabPage = new("Pan and EQ"); - private readonly AccessibleCheckBox enableEqForPeersBox = new() { Text = "Enable &EQ for peers (Alt+E)", AccessibleName = "Enable EQ for peers", AutoSize = true }; - private readonly AccessibleCheckBox enablePanForPeersBox = new() { Text = "Enable &pan for peers (Alt+P)", AccessibleName = "Enable pan for peers", AutoSize = true }; - private readonly ListBox panEqPeerList = new() { Width = 430, Height = 90, AccessibleName = "Peer to shape" }; + // === Volume, pan and EQ for peers tab — shown only when AppConfig.ShowPanEqTab is on. === + private readonly TabPage panEqTabPage = new("Volume, pan and EQ for peers"); + private readonly AccessibleCheckBox enableAllPeerShapingBox = new() { Text = "Enable volume, pan and &EQ for all peers (Alt+E)", AccessibleName = "Enable volume, pan and EQ for all peers", AutoSize = true }; + // A checklist: ticking a peer applies your shaping to them (a per-peer bypass), and the peer the + // cursor is on is the one the controls below edit. + private readonly CheckedListBox panEqPeerList = new() { Width = 430, Height = 90, IntegralHeight = false, AccessibleName = "Peers (Alt+U)" }; private readonly TrackBar volumeSlider = new() { Minimum = 0, Maximum = 100, Value = 100, SmallChange = 1, LargeChange = 10, TickFrequency = 25, Width = 320 }; private readonly TrackBar panSlider = new() { Minimum = 0, Maximum = 100, Value = 50, SmallChange = 1, LargeChange = 10, TickFrequency = 25, Width = 320 }; private readonly Button resetPeerEqButton = new() { Text = "Set peer E&Q to default (Alt+Q)", AutoSize = true, AccessibleName = "Set peer EQ to default" }; - private readonly ListBox eqModeList = new() { Width = 320, Height = 40, IntegralHeight = false, AccessibleName = "EQ mode" }; + private readonly ListBox eqModeList = new() { Width = 320, Height = 58, IntegralHeight = false, AccessibleName = "EQ mode" }; private readonly FlowLayoutPanel eqBandsPanel = new() { FlowDirection = FlowDirection.TopDown, AutoSize = true, WrapContents = false, Margin = new Padding(0) }; + // Parametric-mode controls (built into eqBandsPanel when the 16-band mode is active). + private readonly Button addBandButton = new() { Text = "&Add band (Alt+A)", AutoSize = true, AccessibleName = "Add band" }; + private readonly Button deleteBandButton = new() { Text = "&Delete band (Alt+D)", AutoSize = true, AccessibleName = "Delete band" }; + private readonly ListBox parametricBandList = new() { Width = 430, Height = 150, IntegralHeight = false, SelectionMode = SelectionMode.MultiExtended, AccessibleName = "Bands (Alt+B)" }; + // Purely-visual EQ response graph (invisible to NVDA). See EqCurveControl. + private readonly EqCurveControl eqCurve = new() { Width = 430, Height = 110, Margin = new Padding(0, 8, 0, 0) }; // Working copy of the active profile's per-peer shaping, keyed by peer address string. Loaded on // profile apply, saved by BuildCurrentProfile, mutated live as the user moves the controls. private Dictionary peerShaping = new(); @@ -778,7 +785,12 @@ public sealed class MainForm : Form ShowQuickProfileSwitch, // Speak the status line aloud through the active screen reader (issue #13). Screen-reader // specific; the global hotkey is unset by default (the user binds it in Keyboard shortcuts). - SpeakStatusLine); + SpeakStatusLine, + // Toggle the "Enable volume, pan and EQ for all peers" master switch. Flipping .Checked + // routes through the CheckedChanged handler (re-applies shaping) and, being an + // AccessibleCheckBox, announces the new state to NVDA when the window is focused. Unset + // by default; the user binds it in Keyboard shortcuts. + () => enableAllPeerShapingBox.Checked = !enableAllPeerShapingBox.Checked); // Pipe hotkey controller diagnostics into the main log so we can see, e.g., // "capture send-system-volume-down: OK = Ctrl+Shift+Alt+J" and // "register send-system-volume-down: FAILED = Ctrl+Shift+Alt+J (Win32 error 1409: @@ -3139,26 +3151,35 @@ public sealed class MainForm : Form public override string ToString() => Label; } - /// Builds the "Pan and EQ" tab: two master enables, a connected-peer picker, then the - /// selected peer's pan slider, a reset-EQ button, an EQ-mode picker (3-band / 10-band) and that - /// mode's band sliders. Every control acts on the peer selected in the list, applies in real time, - /// and is saved per profile. See / . + /// Builds the "Volume, pan and EQ for peers" tab: one master switch, a checklist of + /// connected peers (tick = shape that peer), then the selected peer's volume + pan sliders, a + /// reset-EQ button, an EQ-mode picker (3-band simple / 12-band graphic / 16-band parametric) and + /// that mode's controls, plus a purely-visual response curve. Every control acts on the peer the + /// cursor is on, applies in real time, and is saved per profile. See / + /// . private void BuildPanEqTab() { var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), ColumnCount = 1, RowCount = 9, AutoScroll = true }; panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); - enableEqForPeersBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } }; - enablePanForPeersBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } }; + enableAllPeerShapingBox.CheckedChanged += (_, _) => { if (!loadingPanEqControls) { MarkProfileDirty(); ApplyAllPeerShaping(); } }; panEqPeerList.SelectedIndexChanged += (_, _) => OnPanEqPeerSelected(); + panEqPeerList.ItemCheck += OnPeerShapeToggled; + // First-letter navigation on some Windows configs can accidentally toggle the checkbox; do the + // letter-nav ourselves and swallow the default (per the workspace CheckedListBox guidance). + panEqPeerList.KeyDown += OnPeerListKeyDown; volumeSlider.ValueChanged += (_, _) => OnVolumeChanged(); panSlider.ValueChanged += (_, _) => OnPanChanged(); resetPeerEqButton.Click += (_, _) => OnResetPeerEq(); - eqModeList.Items.Add("3 band basic EQ"); - eqModeList.Items.Add("12 band advanced EQ"); + addBandButton.Click += (_, _) => OnAddParametricBand(); + deleteBandButton.Click += (_, _) => OnDeleteParametricBands(); + parametricBandList.KeyDown += OnParametricBandListKeyDown; + eqModeList.Items.Add("3 band simple EQ"); + eqModeList.Items.Add("12 band advanced graphic EQ"); + eqModeList.Items.Add("16 band parametric EQ"); eqModeList.SelectedIndexChanged += (_, _) => OnEqModeChanged(); - var peerLabel = new MnemonicLabel { Text = "Peer to shape (Alt+&U)", AutoSize = true, MnemonicTarget = panEqPeerList }; + var peerLabel = new MnemonicLabel { Text = "Peers (Alt+&U)", AutoSize = true, MnemonicTarget = panEqPeerList }; var volumeLabel = new MnemonicLabel { Text = "Vo&lume (Alt+L)", AutoSize = true, MnemonicTarget = volumeSlider }; var panLabel = new MnemonicLabel { Text = "Pa&n (Alt+N)", AutoSize = true, MnemonicTarget = panSlider }; var modeLabel = new MnemonicLabel { Text = "EQ &mode (Alt+M)", AutoSize = true, MnemonicTarget = eqModeList }; @@ -3173,15 +3194,15 @@ public sealed class MainForm : Form modeRow.Controls.Add(modeLabel); modeRow.Controls.Add(eqModeList); - panel.Controls.Add(enableEqForPeersBox, 0, 0); - panel.Controls.Add(enablePanForPeersBox, 0, 1); - panel.Controls.Add(peerLabel, 0, 2); - panel.Controls.Add(panEqPeerList, 0, 3); - panel.Controls.Add(volumeRow, 0, 4); - panel.Controls.Add(panRow, 0, 5); - panel.Controls.Add(resetPeerEqButton, 0, 6); - panel.Controls.Add(modeRow, 0, 7); - panel.Controls.Add(eqBandsPanel, 0, 8); + panel.Controls.Add(enableAllPeerShapingBox, 0, 0); + panel.Controls.Add(peerLabel, 0, 1); + panel.Controls.Add(panEqPeerList, 0, 2); + panel.Controls.Add(volumeRow, 0, 3); + panel.Controls.Add(panRow, 0, 4); + panel.Controls.Add(resetPeerEqButton, 0, 5); + panel.Controls.Add(modeRow, 0, 6); + panel.Controls.Add(eqBandsPanel, 0, 7); + panel.Controls.Add(eqCurve, 0, 8); panEqTabPage.Controls.Add(panel); RefreshPanEqPeerList(); @@ -3226,17 +3247,25 @@ public sealed class MainForm : Form lastPanEqPeerSignature = signature; var prevKey = (panEqPeerList.SelectedItem as PanEqPeerItem)?.Key ?? selectedShapingKey; - panEqPeerList.BeginUpdate(); - panEqPeerList.Items.Clear(); - int idx = -1; - foreach (var d in desired) + loadingPanEqControls = true; + try { - int i = panEqPeerList.Items.Add(d); - if (d.Key == prevKey) idx = i; + panEqPeerList.BeginUpdate(); + panEqPeerList.Items.Clear(); + int idx = -1; + foreach (var d in desired) + { + // Tick reflects the peer's saved Enabled flag (a per-peer bypass). Add(item, isChecked) + // sets the initial state without raising ItemCheck. + bool ticked = GetShaping(d.Key)?.Enabled ?? true; + int i = panEqPeerList.Items.Add(d, ticked); + if (d.Key == prevKey) idx = i; + } + if (idx < 0 && panEqPeerList.Items.Count > 0) idx = 0; + if (idx >= 0) panEqPeerList.SelectedIndex = idx; + panEqPeerList.EndUpdate(); } - if (idx < 0 && panEqPeerList.Items.Count > 0) idx = 0; - if (idx >= 0) panEqPeerList.SelectedIndex = idx; - panEqPeerList.EndUpdate(); + finally { loadingPanEqControls = false; } ApplyAllPeerShaping(); } @@ -3254,16 +3283,60 @@ public sealed class MainForm : Form UpdateVolumeAccessibleName(); panSlider.Value = Math.Clamp((int)Math.Round(s.Pan * 50f) + 50, 0, 100); UpdatePanAccessibleName(); - eqModeList.SelectedIndex = s.EqMode == PeerEqMode.Advanced10Band ? 1 : 0; + eqModeList.SelectedIndex = (int)s.EqMode; // enum values line up with the picker rows RebuildEqBandSliders(); volumeSlider.Enabled = enabled; panSlider.Enabled = enabled; resetPeerEqButton.Enabled = enabled; eqModeList.Enabled = enabled; + UpdateEqCurve(); } finally { loadingPanEqControls = false; } } + // The three EQ-mode picker rows map one-to-one onto the PeerEqMode enum values. + private static PeerEqMode ModeForIndex(int index) => index switch + { + 2 => PeerEqMode.Parametric16Band, + 1 => PeerEqMode.Advanced10Band, + _ => PeerEqMode.Simple3Band, + }; + + /// Fired when the user ticks/unticks a peer in the checklist — flips that peer's per-peer + /// bypass and re-applies its shaping immediately. + private void OnPeerShapeToggled(object? sender, ItemCheckEventArgs e) + { + if (loadingPanEqControls) return; + if (panEqPeerList.Items[e.Index] is not PanEqPeerItem item) return; + GetOrCreateShaping(item.Key).Enabled = e.NewValue == CheckState.Checked; + MarkProfileDirty(); + // The check state isn't committed until after this event returns, so defer the re-apply. + BeginInvoke(() => ApplyPeerShaping(item.Key)); + } + + // Manual first-letter navigation so a letter key never toggles the tick (workspace guidance). + private void OnPeerListKeyDown(object? sender, KeyEventArgs e) + { + if (e.Modifiers != Keys.None) return; + char c = (char)e.KeyValue; + if (!char.IsLetterOrDigit(c)) return; + int count = panEqPeerList.Items.Count; + if (count == 0) return; + int start = panEqPeerList.SelectedIndex; + for (int step = 1; step <= count; step++) + { + int i = (start + step) % count; + if (panEqPeerList.Items[i]?.ToString() is string t && t.Length > 0 + && char.ToUpperInvariant(t[0]) == char.ToUpperInvariant(c)) + { + panEqPeerList.SelectedIndex = i; + break; + } + } + e.Handled = true; + e.SuppressKeyPress = true; + } + private PeerShaping? GetShaping(string? key) => key is not null && peerShaping.TryGetValue(key, out var s) ? s : null; private PeerShaping GetOrCreateShaping(string? key) @@ -3318,9 +3391,10 @@ public sealed class MainForm : Form private void OnEqModeChanged() { if (loadingPanEqControls || selectedShapingKey is null) return; - GetOrCreateShaping(selectedShapingKey).EqMode = eqModeList.SelectedIndex == 1 ? PeerEqMode.Advanced10Band : PeerEqMode.Simple3Band; + GetOrCreateShaping(selectedShapingKey).EqMode = ModeForIndex(eqModeList.SelectedIndex); RebuildEqBandSliders(); ApplyPeerShaping(selectedShapingKey); + UpdateEqCurve(); MarkProfileDirty(); } @@ -3330,8 +3404,10 @@ public sealed class MainForm : Form var s = GetOrCreateShaping(selectedShapingKey); Array.Clear(s.SimpleBandsDb); Array.Clear(s.AdvancedBandsDb); + s.ParametricBands.Clear(); // reset clears the 16-band parametric list too RebuildEqBandSliders(); ApplyPeerShaping(selectedShapingKey); + UpdateEqCurve(); MarkProfileDirty(); } @@ -3342,16 +3418,27 @@ public sealed class MainForm : Form try { eqBandsPanel.SuspendLayout(); + // Clear the panel. The three parametric controls are persistent members reused across + // rebuilds — remove but never dispose them; everything else (slider rows, the bands label) + // is freshly built each time and disposed here. + var persistent = new Control[] { addBandButton, deleteBandButton, parametricBandList }; while (eqBandsPanel.Controls.Count > 0) { var c = eqBandsPanel.Controls[0]; eqBandsPanel.Controls.RemoveAt(0); - c.Dispose(); + if (Array.IndexOf(persistent, c) < 0) c.Dispose(); } eqBandSliders.Clear(); var s = GetShaping(selectedShapingKey); - var mode = eqModeList.SelectedIndex == 1 ? PeerEqMode.Advanced10Band : PeerEqMode.Simple3Band; + var mode = ModeForIndex(eqModeList.SelectedIndex); + if (mode == PeerEqMode.Parametric16Band) + { + BuildParametricPanel(); + eqBandsPanel.ResumeLayout(); + return; + } + var bands = mode == PeerEqMode.Advanced10Band ? PeerEqBands.Advanced : PeerEqBands.Simple; var gains = s is null ? null : (mode == PeerEqMode.Advanced10Band ? s.AdvancedBandsDb : s.SimpleBandsDb); @@ -3387,7 +3474,7 @@ public sealed class MainForm : Form { if (loadingPanEqControls || selectedShapingKey is null || slider.Tag is not int i) return; var s = GetOrCreateShaping(selectedShapingKey); - var mode = eqModeList.SelectedIndex == 1 ? PeerEqMode.Advanced10Band : PeerEqMode.Simple3Band; + var mode = ModeForIndex(eqModeList.SelectedIndex); var gains = mode == PeerEqMode.Advanced10Band ? s.AdvancedBandsDb : s.SimpleBandsDb; var bands = mode == PeerEqMode.Advanced10Band ? PeerEqBands.Advanced : PeerEqBands.Simple; if (i >= 0 && i < gains.Length) @@ -3396,42 +3483,175 @@ public sealed class MainForm : Form UpdateBandAccessibleName(slider, bands[i].Label); } ApplyPeerShaping(selectedShapingKey); + UpdateEqCurve(); MarkProfileDirty(); } private static void UpdateBandAccessibleName(TrackBar slider, string label) { float db = (slider.Value - 50) / 50f * PeerEqBands.MaxGainDb; - string desc = MathF.Abs(db) < 0.5f ? "flat" : $"{(db > 0 ? "+" : "")}{db:0} dB"; - slider.AccessibleName = $"{label}: {desc}"; + slider.AccessibleName = $"{label}: {FormatGainDb(db)}"; } - /// Builds one peer's DSP chain (honouring the two master enables) and pushes it to the - /// receiver, so the change is heard immediately. A null chain (nothing to do) clears any prior one. + // dB spoken as words — most NVDA users run with punctuation off and would never hear a "+" sign, + // so a boost must say "plus". ("minus" comes through on its own, but we spell both for symmetry.) + private static string FormatGainDb(float db) + { + if (MathF.Abs(db) < 0.5f) return "flat"; + return db > 0 ? $"plus {db:0} dB" : $"minus {MathF.Abs(db):0} dB"; + } + + /// Whether a given peer is shaped right now: the profile-wide master switch AND that peer's + /// own tick (per-peer bypass) both have to be on. + private bool ShapingActiveFor(string? key) + => enableAllPeerShapingBox.Checked && (GetShaping(key)?.Enabled ?? true); + + /// Builds one peer's DSP chain (honouring the master switch and the peer's own tick) and + /// pushes it to the receiver, so the change is heard immediately. A null chain (nothing to do) + /// clears any prior one. private void ApplyPeerShaping(string? key) { if (key is null) return; - System.Net.IPAddress? addr = null; - foreach (var (_, ep) in selectedPeerEndpoints) - if (ep.Address.ToString() == key) { addr = ep.Address; break; } - if (addr is null && !System.Net.IPAddress.TryParse(key, out addr)) return; - var chain = PeerDspChain.Build(GetShaping(key), enablePanForPeersBox.Checked, enableEqForPeersBox.Checked); + var addr = ResolvePeerAddress(key); + if (addr is null) return; + var chain = PeerDspChain.Build(GetShaping(key), ShapingActiveFor(key)); receiver.SetPeerDsp(addr, chain); } - /// Pushes shaping for every currently-connected peer. Used when a master enable flips or a + /// Pushes shaping for every currently-connected peer. Used when the master switch flips or a /// profile loads / the connected set changes. private void ApplyAllPeerShaping() { var seen = new HashSet(); foreach (var (_, ep) in selectedPeerEndpoints) { - if (!seen.Add(ep.Address.ToString())) continue; - var chain = PeerDspChain.Build(GetShaping(ep.Address.ToString()), enablePanForPeersBox.Checked, enableEqForPeersBox.Checked); + var key = ep.Address.ToString(); + if (!seen.Add(key)) continue; + var chain = PeerDspChain.Build(GetShaping(key), ShapingActiveFor(key)); receiver.SetPeerDsp(ep.Address, chain); } } + private System.Net.IPAddress? ResolvePeerAddress(string? key) + { + if (key is null) return null; + foreach (var (_, ep) in selectedPeerEndpoints) + if (ep.Address.ToString() == key) return ep.Address; + return System.Net.IPAddress.TryParse(key, out var addr) ? addr : null; + } + + // === 16-band parametric EQ === + + /// Populates with the parametric controls (Add band, the band + /// list, Delete band). The three controls are persistent members reused across rebuilds. + private void BuildParametricPanel() + { + var bandsLabel = new MnemonicLabel { Text = "Bands (Alt+&B)", AutoSize = true, MnemonicTarget = parametricBandList }; + eqBandsPanel.Controls.Add(addBandButton); + eqBandsPanel.Controls.Add(bandsLabel); + eqBandsPanel.Controls.Add(parametricBandList); + eqBandsPanel.Controls.Add(deleteBandButton); + RefreshParametricBandList(); + } + + /// Rebuilds the band list from the selected peer, sorted bass→treble, each row spelling its + /// dB in words. Also enables/disables Add (capped at 16 bands) and Delete. + private void RefreshParametricBandList() + { + loadingPanEqControls = true; + try + { + parametricBandList.BeginUpdate(); + parametricBandList.Items.Clear(); + var s = GetShaping(selectedShapingKey); + if (s is not null) + { + foreach (var band in s.ParametricBands.OrderBy(b => b.StartHz).ThenBy(b => b.EndHz)) + parametricBandList.Items.Add(new ParametricBandItem(band)); + } + parametricBandList.EndUpdate(); + int count = s?.ParametricBands.Count ?? 0; + bool havePeer = selectedShapingKey is not null; + parametricBandList.Enabled = havePeer; + addBandButton.Enabled = havePeer && count < PeerEqBands.ParametricMaxBands; + deleteBandButton.Enabled = havePeer && count > 0; + } + finally { loadingPanEqControls = false; } + } + + private void OnAddParametricBand() + { + if (selectedShapingKey is null) return; + var s = GetOrCreateShaping(selectedShapingKey); + if (s.ParametricBands.Count >= PeerEqBands.ParametricMaxBands) return; + + // Live preview: while the dialog is open, apply the in-progress band on top of the saved bands + // so the peer's sound changes as the user moves the values; null reverts to the saved shaping. + void Preview(ParametricBand? band) + { + var saved = GetShaping(selectedShapingKey); + var addr = ResolvePeerAddress(selectedShapingKey); + if (saved is null || addr is null) return; + if (band is null) { ApplyPeerShaping(selectedShapingKey); return; } + var temp = new PeerShaping + { + Enabled = saved.Enabled, + Pan = saved.Pan, + Volume = saved.Volume, + EqMode = PeerEqMode.Parametric16Band, + ParametricBands = new List(saved.ParametricBands) { band }, + }; + receiver.SetPeerDsp(addr, PeerDspChain.Build(temp, ShapingActiveFor(selectedShapingKey))); + } + + using var dlg = new AddBandDialog(Preview); + if (dlg.ShowDialog(this) == DialogResult.OK) + { + s.ParametricBands.Add(dlg.Result); + RefreshParametricBandList(); + MarkProfileDirty(); + } + ApplyPeerShaping(selectedShapingKey); // settle on the real saved shaping either way + UpdateEqCurve(); + } + + private void OnDeleteParametricBands() + { + if (selectedShapingKey is null) return; + var s = GetShaping(selectedShapingKey); + if (s is null || parametricBandList.SelectedItems.Count == 0) return; + int firstIdx = parametricBandList.SelectedIndex; + var toRemove = parametricBandList.SelectedItems.Cast().Select(x => x.Band).ToList(); + foreach (var band in toRemove) s.ParametricBands.Remove(band); + RefreshParametricBandList(); + // Put focus on whatever now occupies the first removed slot so NVDA announces it. + if (parametricBandList.Items.Count > 0) + parametricBandList.SelectedIndex = Math.Clamp(firstIdx, 0, parametricBandList.Items.Count - 1); + ApplyPeerShaping(selectedShapingKey); + UpdateEqCurve(); + MarkProfileDirty(); + } + + private void OnParametricBandListKeyDown(object? sender, KeyEventArgs e) + { + if (e.KeyCode == Keys.Delete) + { + OnDeleteParametricBands(); + e.Handled = true; + e.SuppressKeyPress = true; + } + } + + private void UpdateEqCurve() => eqCurve.SetResponse(GetShaping(selectedShapingKey)); + + // One list row per parametric band, e.g. "200 Hz to 2000 Hz, plus 3 dB". Holds the band by + // reference so Delete can remove the exact object. + private sealed class ParametricBandItem(ParametricBand band) + { + public ParametricBand Band { get; } = band; + public override string ToString() => $"{Band.StartHz:0} Hz to {Band.EndHz:0} Hz, {FormatGainDb(Band.GainDb)}"; + } + /// Send-side controls: codec + packet size on row 0, lock-to-audio-clock on /// row 1. The codec and packet-size combo share a row because they're tightly coupled /// (changing the codec resets the meaningful packet sizes). Lock-to-clock is a sender- @@ -6666,7 +6886,8 @@ public sealed class MainForm : Form // clearing the signature makes RefreshPanEqPeerList rebuild and re-push for this profile. peerShaping = p.PeerShaping is null ? new() : new(p.PeerShaping); loadingPanEqControls = true; - try { enablePanForPeersBox.Checked = p.EnablePanForPeers; enableEqForPeersBox.Checked = p.EnableEqForPeers; } + // Single master switch now. Migrate older profiles: either legacy flag being on turns it on. + try { enableAllPeerShapingBox.Checked = p.EnableAllPeerShaping || p.EnablePanForPeers || p.EnableEqForPeers; } finally { loadingPanEqControls = false; } lastPanEqPeerSignature = ""; } @@ -6845,8 +7066,7 @@ public sealed class MainForm : Form profile.SelectedWasapiSendInputs = ExtractCheckedDeviceIds(sendInputDevicesList); profile.SelectedAsioSendInputs = ExtractCheckedDeviceIds(asioSendDevicesList); profile.SelectedConnectedPeers = GatherSelectedPeerEntries(); - profile.EnablePanForPeers = enablePanForPeersBox.Checked; - profile.EnableEqForPeers = enableEqForPeersBox.Checked; + profile.EnableAllPeerShaping = enableAllPeerShapingBox.Checked; profile.PeerShaping = peerShaping; return profile; } @@ -7671,6 +7891,7 @@ public sealed class MainForm : Form { sendMyAudioCheckbox.AccessibleDescription = DescribeHotkey(hotkeyController.SendMuteHotkey); receiveAudioCheckbox.AccessibleDescription = DescribeHotkey(hotkeyController.ReceiveMuteHotkey); + enableAllPeerShapingBox.AccessibleDescription = DescribeHotkey(hotkeyController.ToggleAllPeerShapingHotkey); if (startStopRecordingMenuItem is not null) { startStopRecordingMenuItem.AccessibleDescription = DescribeHotkey(hotkeyController.ToggleRecordingHotkey); diff --git a/src/RemSound.App/MainFormHotkeyController.cs b/src/RemSound.App/MainFormHotkeyController.cs index a7e291b..6f0d37a 100644 --- a/src/RemSound.App/MainFormHotkeyController.cs +++ b/src/RemSound.App/MainFormHotkeyController.cs @@ -35,6 +35,9 @@ internal sealed class MainFormHotkeyController : IDisposable // specific (issue #13); unset by default. Global so it reads the status even when RemSound isn't // focused — the case NVDA can't otherwise cover. private readonly Action speakStatusLine; + // Toggle the "Enable volume, pan and EQ for all peers" master switch from anywhere. Unset by + // default; global so it works even when RemSound isn't focused. + private readonly Action toggleAllPeerShaping; private Form? owner; private HotkeyInfo sendMuteHotkey; private HotkeyInfo receiveMuteHotkey; @@ -50,6 +53,7 @@ internal sealed class MainFormHotkeyController : IDisposable private HotkeyInfo systemMuteToggleHotkey; private HotkeyInfo quickProfileSwitchHotkey; private HotkeyInfo speakStatusLineHotkey; + private HotkeyInfo toggleAllPeerShapingHotkey; private GlobalHotkey? sendMuteGlobalHotkey; private GlobalHotkey? receiveMuteGlobalHotkey; private GlobalHotkey? trayGlobalHotkey; @@ -64,6 +68,7 @@ internal sealed class MainFormHotkeyController : IDisposable private GlobalHotkey? systemMuteToggleGlobalHotkey; private GlobalHotkey? quickProfileSwitchGlobalHotkey; private GlobalHotkey? speakStatusLineGlobalHotkey; + private GlobalHotkey? toggleAllPeerShapingGlobalHotkey; /// Optional log sink. MainForm wires this to logFile.Event(...) so each /// hotkey change writes a clear trail of "user opened capture", "captured X", "registered X @@ -94,7 +99,8 @@ internal sealed class MainFormHotkeyController : IDisposable Action sendSystemVolumeDown, Action sendSystemMuteToggle, Action quickProfileSwitch, - Action speakStatusLine) + Action speakStatusLine, + Action toggleAllPeerShaping) { this.settingsStore = settingsStore; this.toggleSend = toggleSend; @@ -111,6 +117,7 @@ internal sealed class MainFormHotkeyController : IDisposable this.sendSystemMuteToggle = sendSystemMuteToggle; this.quickProfileSwitch = quickProfileSwitch; this.speakStatusLine = speakStatusLine; + this.toggleAllPeerShaping = toggleAllPeerShaping; sendMuteHotkey = settingsStore.LoadSendMuteHotkey(); receiveMuteHotkey = settingsStore.LoadReceiveMuteHotkey(); trayHotkey = settingsStore.LoadTrayHotkey(); @@ -125,6 +132,7 @@ internal sealed class MainFormHotkeyController : IDisposable systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey(); quickProfileSwitchHotkey = settingsStore.LoadQuickProfileSwitchHotkey(); speakStatusLineHotkey = settingsStore.LoadSpeakStatusLineHotkey(); + toggleAllPeerShapingHotkey = settingsStore.LoadToggleAllPeerShapingHotkey(); } public void Initialize(Form ownerForm) @@ -144,6 +152,7 @@ internal sealed class MainFormHotkeyController : IDisposable systemMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm); quickProfileSwitchGlobalHotkey = new GlobalHotkey(ownerForm); speakStatusLineGlobalHotkey = new GlobalHotkey(ownerForm); + toggleAllPeerShapingGlobalHotkey = new GlobalHotkey(ownerForm); sendMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleSend); receiveMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleReceive); trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray); @@ -158,6 +167,7 @@ internal sealed class MainFormHotkeyController : IDisposable systemMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemMuteToggle); quickProfileSwitchGlobalHotkey.Pressed += () => InvokeOnOwner(quickProfileSwitch); speakStatusLineGlobalHotkey.Pressed += () => InvokeOnOwner(speakStatusLine); + toggleAllPeerShapingGlobalHotkey.Pressed += () => InvokeOnOwner(toggleAllPeerShaping); RegisterSendMuteHotkey(); RegisterReceiveMuteHotkey(); RegisterTrayHotkey(); @@ -172,6 +182,7 @@ internal sealed class MainFormHotkeyController : IDisposable RegisterSystemMuteToggleHotkey(); RegisterQuickProfileSwitchHotkey(); RegisterSpeakStatusLineHotkey(); + RegisterToggleAllPeerShapingHotkey(); } /// Re-read every hotkey from the (now machine-wide) settings store and re-register it. @@ -193,6 +204,7 @@ internal sealed class MainFormHotkeyController : IDisposable systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey(); quickProfileSwitchHotkey = settingsStore.LoadQuickProfileSwitchHotkey(); speakStatusLineHotkey = settingsStore.LoadSpeakStatusLineHotkey(); + toggleAllPeerShapingHotkey = settingsStore.LoadToggleAllPeerShapingHotkey(); RegisterSendMuteHotkey(); RegisterReceiveMuteHotkey(); RegisterTrayHotkey(); @@ -207,6 +219,7 @@ internal sealed class MainFormHotkeyController : IDisposable RegisterSystemMuteToggleHotkey(); RegisterQuickProfileSwitchHotkey(); RegisterSpeakStatusLineHotkey(); + RegisterToggleAllPeerShapingHotkey(); } public void ShowKeyboardShortcutsDialog(IWin32Window dialogOwner) @@ -323,6 +336,7 @@ internal sealed class MainFormHotkeyController : IDisposable list.Items.Add($"Send Windows global mute toggle to peers: {systemMuteToggleHotkey}"); list.Items.Add($"Quick profile switch (open a list of all profiles): {quickProfileSwitchHotkey}"); list.Items.Add($"Speak the RemSound status information from anywhere (screen reader only): {speakStatusLineHotkey}"); + list.Items.Add($"Toggle volume, pan and EQ for all peers: {toggleAllPeerShapingHotkey}"); if (prev >= 0 && prev < list.Items.Count) { list.SelectedIndex = prev; @@ -361,6 +375,7 @@ internal sealed class MainFormHotkeyController : IDisposable case 11: ChangeSystemMuteToggleHotkey(dialog); break; case 12: ChangeQuickProfileSwitchHotkey(dialog); break; case 13: ChangeSpeakStatusLineHotkey(dialog); break; + case 14: ChangeToggleAllPeerShapingHotkey(dialog); break; default: return; } RefreshList(); @@ -394,6 +409,7 @@ internal sealed class MainFormHotkeyController : IDisposable case 11: ApplyUnset("send-system-mute-toggle", h => systemMuteToggleHotkey = h, RegisterSystemMuteToggleHotkey, settingsStore.SaveSystemMuteToggleHotkey); break; case 12: ApplyUnset("quick-profile-switch", h => quickProfileSwitchHotkey = h, RegisterQuickProfileSwitchHotkey, settingsStore.SaveQuickProfileSwitchHotkey); break; case 13: ApplyUnset("speak-status-line", h => speakStatusLineHotkey = h, RegisterSpeakStatusLineHotkey, settingsStore.SaveSpeakStatusLineHotkey); break; + case 14: ApplyUnset("toggle-all-peer-shaping", h => toggleAllPeerShapingHotkey = h, RegisterToggleAllPeerShapingHotkey, settingsStore.SaveToggleAllPeerShapingHotkey); break; default: return; } RefreshList(); @@ -475,6 +491,7 @@ internal sealed class MainFormHotkeyController : IDisposable systemMuteToggleGlobalHotkey?.Dispose(); quickProfileSwitchGlobalHotkey?.Dispose(); speakStatusLineGlobalHotkey?.Dispose(); + toggleAllPeerShapingGlobalHotkey?.Dispose(); } public HotkeyInfo SendMuteHotkey => sendMuteHotkey; @@ -491,6 +508,7 @@ internal sealed class MainFormHotkeyController : IDisposable public HotkeyInfo SystemMuteToggleHotkey => systemMuteToggleHotkey; public HotkeyInfo QuickProfileSwitchHotkey => quickProfileSwitchHotkey; public HotkeyInfo SpeakStatusLineHotkey => speakStatusLineHotkey; + public HotkeyInfo ToggleAllPeerShapingHotkey => toggleAllPeerShapingHotkey; /// Open the capture dialog, log what came back, and (on a successful capture) /// run with the captured hotkey. Centralises the boilerplate @@ -636,6 +654,13 @@ internal sealed class MainFormHotkeyController : IDisposable settingsStore.SaveSpeakStatusLineHotkey(h); }); + private void ChangeToggleAllPeerShapingHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "toggle-all-peer-shaping", h => + { + toggleAllPeerShapingHotkey = h; + RegisterToggleAllPeerShapingHotkey(); + settingsStore.SaveToggleAllPeerShapingHotkey(h); + }); + // Hotkeys come in two flavours and need different Windows-side registration: // * Toggle hotkeys (mute, tray show/hide) — re-firing on hold would flip state back // and forth. Registered with MOD_NOREPEAT (allowRepeat=false). One press, one fire. @@ -667,6 +692,9 @@ internal sealed class MainFormHotkeyController : IDisposable // Speak status line is a one-shot (press → read the status aloud once); MOD_NOREPEAT (the default) // keeps a held key from re-triggering the speech over and over. private void RegisterSpeakStatusLineHotkey() => RegisterIfSet(speakStatusLineGlobalHotkey, speakStatusLineHotkey, "speak status line"); + // Toggle all-peer shaping is a one-shot toggle; MOD_NOREPEAT (the default) stops a held key + // flipping the master switch on/off/on at auto-repeat rate. + private void RegisterToggleAllPeerShapingHotkey() => RegisterIfSet(toggleAllPeerShapingGlobalHotkey, toggleAllPeerShapingHotkey, "toggle all-peer shaping"); private void RegisterIfSet(GlobalHotkey? globalHotkey, HotkeyInfo hotkey, string description, bool allowRepeat = false) { diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs index 2a94e50..95b42ea 100644 --- a/src/RemSound.App/PreferencesDialog.cs +++ b/src/RemSound.App/PreferencesDialog.cs @@ -273,8 +273,8 @@ internal sealed class PreferencesDialog : Form private readonly AccessibleCheckBox showPanEqTabBox = new() { - Text = "Show the Pan and E&Q tab, for per-peer pan and EQ (Alt+Q)", - AccessibleName = "Show the Pan and EQ tab", + Text = "Show the volume, pan and E&Q for peers tab (Alt+Q)", + AccessibleName = "Show the volume, pan and EQ for peers tab", AutoSize = true, }; diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs index 6751a12..bcbfd4a 100644 --- a/src/RemSound.Core/AppConfig.cs +++ b/src/RemSound.Core/AppConfig.cs @@ -68,11 +68,12 @@ public sealed class AppConfig /// already running quietly". Default false. public bool StartMinimised { get; set; } - /// If true, the main window shows a "Pan and EQ" tab (positioned before "Audio profile") - /// for setting per-peer panning and EQ. Off by default; toggled by "Show pan and EQ tab" on the - /// Preferences General tab. Machine-wide (a UI-visibility preference, like which tabs exist) — the - /// pan/EQ VALUES are saved per profile, in . - public bool ShowPanEqTab { get; set; } + /// If true (the default), the main window shows a "Volume, pan and EQ for peers" tab + /// (positioned before "Audio profile") for setting per-peer volume, panning and EQ. Toggled by + /// "Show the volume, pan and EQ for peers tab" on the Preferences General tab. Machine-wide (a + /// UI-visibility preference, like which tabs exist) — the shaping VALUES are saved per profile, in + /// . + public bool ShowPanEqTab { get; set; } = true; /// If true (the default), RemSound plays the startup cue once, right after this /// copy wins the single-instance takeover and before the profile loads. Machine-wide (not @@ -173,6 +174,10 @@ public sealed class AppConfig public HotkeyRecord? SystemMuteToggleHotkey { get; set; } public HotkeyRecord? QuickProfileSwitchHotkey { get; set; } public HotkeyRecord? SpeakStatusLineHotkey { get; set; } + /// Global shortcut that toggles the "Enable volume, pan and EQ for all peers" master + /// switch. Machine-wide and unset by default; NOT stored in any profile (unlike the shaping + /// values). The user binds it in Keyboard shortcuts. + public HotkeyRecord? ToggleAllPeerShapingHotkey { get; set; } /// True once the one-time "your keyboard shortcuts are now shared across profiles" notice /// has been shown (to an upgrader), or silently marked done on a fresh install that had nothing to diff --git a/src/RemSound.Core/PeerShaping.cs b/src/RemSound.Core/PeerShaping.cs index f2bf66b..8b52f57 100644 --- a/src/RemSound.Core/PeerShaping.cs +++ b/src/RemSound.Core/PeerShaping.cs @@ -1,35 +1,64 @@ namespace RemSound.Core; -/// Which EQ the user is shaping a peer with. The two modes are INDEPENDENT: switching -/// between them keeps each mode's own band gains — nothing is carried over. The active mode is the +/// Which EQ the user is shaping a peer with. The three modes are INDEPENDENT: switching +/// between them keeps each mode's own settings — nothing is carried over. The active mode is the /// one that's applied to the sound. public enum PeerEqMode { + /// 3-band tone control (bass / mids / treble). Display name: "3 band simple EQ". Simple3Band = 0, + /// 12-band graphic EQ with fixed centre frequencies. Display name: "12 band advanced + /// graphic EQ". (The enum member keeps its historic name; the numeric value is what's persisted.) Advanced10Band = 1, + /// Up to 16 user-defined parametric bands, each a boost/cut across a start→end frequency + /// range. Display name: "16 band parametric EQ". + Parametric16Band = 2, } -/// Per-peer pan + EQ, saved per profile (see ). Keyed in +/// One user-defined parametric EQ band: a boost or cut spread across the range from +/// to . Realised as a peaking filter centred on the +/// geometric mean of the two frequencies, widened to span the range (see +/// ). +public sealed class ParametricBand +{ + public float StartHz { get; set; } + public float EndHz { get; set; } + /// Gain in dB, -12..+12. + public float GainDb { get; set; } + + public ParametricBand Clone() => new() { StartHz = StartHz, EndHz = EndHz, GainDb = GainDb }; +} + +/// Per-peer volume + pan + EQ, saved per profile (see ). Keyed in /// the profile by the same peer-entry string as . public sealed class PeerShaping { + /// Whether this peer is shaped at all. When false the peer passes through untouched even + /// while the profile-wide master switch is on — a per-peer bypass that keeps the settings below. + /// Defaults to true so a freshly-seen peer is covered the moment the master switch is turned on. + public bool Enabled { get; set; } = true; + /// -1 (full left) .. 0 (centre) .. +1 (full right). public float Pan { get; set; } /// Per-peer playback level, 0..1 (1 = 100%, unity). An individual fader for this peer, - /// on top of the global volume. Always applied (no master switch); 100% is transparent. + /// on top of the global volume. 100% is transparent. public float Volume { get; set; } = 1f; /// Which EQ mode is currently active for this peer. public PeerEqMode EqMode { get; set; } = PeerEqMode.Simple3Band; /// Gains in dB (-12..+12) for the 3 simple bands, in the order of - /// (bass, mids, treble). Kept independently of the advanced bands. + /// (bass, mids, treble). Kept independently of the other modes. public float[] SimpleBandsDb { get; set; } = new float[3]; /// Gains in dB (-12..+12) for the 12 advanced graphic-EQ bands, in the order of - /// . Kept independently of the simple bands. + /// . Kept independently of the other modes. public float[] AdvancedBandsDb { get; set; } = new float[12]; + + /// The user's parametric bands (up to ). Kept + /// independently of the two graphic modes. Empty = flat. + public List ParametricBands { get; set; } = new(); } /// The fixed EQ band layouts. The user sets each band's GAIN; the centre frequencies are @@ -44,6 +73,13 @@ public static class PeerEqBands /// Maximum boost/cut for any band, in dB. Sliders run -12..+12. public const float MaxGainDb = 12f; + /// Most parametric bands a peer can hold. + public const int ParametricMaxBands = 16; + + /// Lowest / highest frequency a parametric band edge can be set to. + public const float ParametricMinHz = 20f; + public const float ParametricMaxHz = 20000f; + /// 3-band "tone control": bass (low shelf), mids (peaking), treble (high shelf). public static readonly (string Label, double Freq)[] Simple = [ @@ -69,4 +105,21 @@ public static class PeerEqBands ("8 kHz", 8000.0), ("16 kHz", 16000.0), ]; + + /// Converts a parametric band's start/end frequency range into a peaking-filter centre + /// frequency and Q. Centre is the geometric mean of the two edges; Q comes from the range's width + /// in octaves (the standard RBJ bandwidth-to-Q relation). Both the DSP and the on-screen response + /// curve call this so they always agree. + public static void ParametricToPeaking(float startHz, float endHz, out float centerHz, out float q) + { + startHz = Math.Clamp(startHz, ParametricMinHz, ParametricMaxHz); + endHz = Math.Clamp(endHz, ParametricMinHz, ParametricMaxHz); + if (endHz <= startHz) endHz = MathF.Min(startHz * 1.01f, ParametricMaxHz); + centerHz = MathF.Sqrt(startHz * endHz); + float bwOct = MathF.Log2(endHz / startHz); + if (bwOct < 0.01f) bwOct = 0.01f; + float twoPowBw = MathF.Pow(2f, bwOct); + q = MathF.Sqrt(twoPowBw) / (twoPowBw - 1f); + q = Math.Clamp(q, 0.1f, 12f); + } } diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs index 6f5f14c..c3ab717 100644 --- a/src/RemSound.Core/Profile.cs +++ b/src/RemSound.Core/Profile.cs @@ -183,12 +183,15 @@ public sealed class Profile /// (the connection simply waits or drops rather than moving). Off by default. (#17) public bool LockPeerAddresses { get; set; } - // === Per-peer pan and EQ (jam mixing) === - /// Master switch for this profile: apply each connected peer's saved PAN. Off by default. - /// The pan values themselves live in . - public bool EnablePanForPeers { get; set; } + // === Per-peer volume, pan and EQ (jam mixing) === + /// Single master switch for this profile: apply each connected peer's saved VOLUME, PAN and + /// EQ. Off by default. The values themselves live in ; a peer can also be + /// bypassed individually via . + public bool EnableAllPeerShaping { get; set; } - /// Master switch for this profile: apply each connected peer's saved EQ. Off by default. + /// Legacy separate master switches (pre-collapse). Kept only so older profile JSON still + /// deserialises and migrates: on load, either being true turns the single master switch on. + public bool EnablePanForPeers { get; set; } public bool EnableEqForPeers { get; set; } /// Per-peer pan + EQ, keyed by the SAME peer-entry string used in diff --git a/src/RemSound.Core/RemSoundSettingsStore.cs b/src/RemSound.Core/RemSoundSettingsStore.cs index a6d0dc4..d358346 100644 --- a/src/RemSound.Core/RemSoundSettingsStore.cs +++ b/src/RemSound.Core/RemSoundSettingsStore.cs @@ -82,6 +82,10 @@ public sealed class RemSoundSettingsStore AppConfig.Load().SpeakStatusLineHotkey?.ToHotkeyInfo() ?? HotkeyInfo.Unset; public void SaveSpeakStatusLineHotkey(HotkeyInfo hotkey) => SaveGlobalHotkey(c => c.SpeakStatusLineHotkey = HotkeyRecord.From(hotkey)); + public HotkeyInfo LoadToggleAllPeerShapingHotkey() => + AppConfig.Load().ToggleAllPeerShapingHotkey?.ToHotkeyInfo() ?? HotkeyInfo.Unset; + public void SaveToggleAllPeerShapingHotkey(HotkeyInfo hotkey) => SaveGlobalHotkey(c => c.ToggleAllPeerShapingHotkey = HotkeyRecord.From(hotkey)); + public bool LoadAcceptRemoteVolumeCommands(bool defaultValue = false) => Try(() => Load()?.AcceptRemoteVolumeCommands) ?? defaultValue; diff --git a/src/RemSound.Receiver/PeerDspChain.cs b/src/RemSound.Receiver/PeerDspChain.cs index 832d5d6..c3913fe 100644 --- a/src/RemSound.Receiver/PeerDspChain.cs +++ b/src/RemSound.Receiver/PeerDspChain.cs @@ -37,29 +37,44 @@ public sealed class PeerDspChain /// and it pays nothing. public bool IsNoOp => !hasGain && left.Length == 0; - /// Builds a chain for one peer from its saved shaping and the profile's two master - /// switches. Returns null if there's nothing to do — pan disabled or centred, and EQ disabled or - /// completely flat. Runs on the UI thread; the result is swapped onto the audio thread atomically. - public static PeerDspChain? Build(PeerShaping? shaping, bool applyPan, bool applyEq) + /// Builds a chain for one peer from its saved shaping and the profile's single master + /// switch (volume + pan + EQ together). Returns null if there's nothing to do — master off, or pan + /// centred, volume 100% and EQ flat. Runs on the UI thread; the result is swapped onto the audio + /// thread atomically. + public static PeerDspChain? Build(PeerShaping? shaping, bool enabled) { - // Pan (only when enabled) and the per-peer volume (always applied) fold into one L/R gain. - // Pan is a balance control: it keeps the peer's stereo image (never sums to mono). Centre is - // unity on both sides; panning toward one side attenuates the OPPOSITE channel, reaching zero - // at the extreme. Volume then scales both sides. So a stereo signal leans left/right and sits - // at the level you set, without ever collapsing to mono. - float pan = applyPan && shaping is not null ? Math.Clamp(shaping.Pan, -1f, 1f) : 0f; + // Master off (or no saved shaping) → the peer passes through untouched. + if (!enabled || shaping is null) return null; + + // Pan and the per-peer volume fold into one L/R gain. Pan is a balance control: it keeps the + // peer's stereo image (never sums to mono). Centre is unity on both sides; panning toward one + // side attenuates the OPPOSITE channel, reaching zero at the extreme. Volume then scales both + // sides. So a stereo signal leans left/right and sits at the level you set, never collapsing. + float pan = Math.Clamp(shaping.Pan, -1f, 1f); float panL = pan > 0f ? 1f - pan : 1f; float panR = pan < 0f ? 1f + pan : 1f; - float vol = shaping is null ? 1f : Math.Clamp(shaping.Volume, 0f, 1f); + float vol = Math.Clamp(shaping.Volume, 0f, 1f); float gainL = panL * vol; float gainR = panR * vol; bool hasGain = gainL != 1f || gainR != 1f; var l = new List(); var r = new List(); - if (applyEq && shaping is not null) + var mode = shaping.EqMode; + if (mode == PeerEqMode.Parametric16Band) + { + foreach (var band in shaping.ParametricBands) + { + if (band is null || MathF.Abs(band.GainDb) < 0.05f) continue; // flat band — skip + PeerEqBands.ParametricToPeaking(band.StartHz, band.EndHz, out float centre, out float q); + float nyquist = PeerEqBands.MixSampleRate / 2f; + if (centre <= 0f || centre >= nyquist) continue; + l.Add(BiQuadFilter.PeakingEQ(PeerEqBands.MixSampleRate, centre, q, band.GainDb)); + r.Add(BiQuadFilter.PeakingEQ(PeerEqBands.MixSampleRate, centre, q, band.GainDb)); + } + } + else { - var mode = shaping.EqMode; var bands = mode == PeerEqMode.Advanced10Band ? PeerEqBands.Advanced : PeerEqBands.Simple; var gains = mode == PeerEqMode.Advanced10Band ? shaping.AdvancedBandsDb : shaping.SimpleBandsDb; for (int i = 0; i < bands.Length; i++)