Volume/pan/EQ tab overhaul: one master switch, peer checklist, 16-band parametric EQ

Reworks the per-peer shaping tab (held for next release):

 * Renamed the tab to "Volume, pan and EQ for peers"; the Preferences toggle now defaults ON.
 * Collapsed the two master switches (Enable EQ / Enable pan) into ONE: "Enable volume, pan and
   EQ for all peers" (Alt+E). Volume now obeys it too. PeerDspChain.Build takes a single enabled
   flag; Profile.EnableAllPeerShaping replaces the two bools (old ones kept for load-migration).
 * Peer picker is now a CheckedListBox: ticking a peer shapes them (per-peer bypass via new
   PeerShaping.Enabled, default true); the focused row is the one the controls edit. Effective
   shaping = master switch AND that peer's tick. Letter-nav suppressed so keys never toggle a tick.
 * Three EQ modes, renamed: "3 band simple EQ", "12 band advanced graphic EQ", and the new
   "16 band parametric EQ" (PeerEqMode.Parametric16Band).
 * Parametric EQ: up to 16 user bands, each a boost/cut across a start->end range (PeerShaping
   .ParametricBands; ParametricToPeaking maps range -> peaking centre+Q, shared by DSP and curve).
   Add band dialog (spin-or-type, numeric-only, live preview, OK/Escape); Bands list sorted
   bass->treble reading "X Hz to Y Hz, plus/minus N dB"; Delete key / Delete button, multi-select.
   Set peer EQ to default clears the parametric list too.
 * dB now spoken as words ("plus 3 dB" / "minus 6 dB" / "flat") on the graphic sliders and the
   parametric list, since NVDA users typically have punctuation off and never hear a "+".
 * New unbound machine-wide global shortcut "Toggle volume, pan and EQ for all peers" (not stored
   in any profile) via the hotkey controller + settings store.
 * Renamed the Inputs/outputs "Set volume for all received audio" to "Master receive volume".
 * Added EqCurveControl: a purely-visual EQ response graph (not focusable, invisible to NVDA).
 * Full manual sweep (readme.html + regenerated MANUAL.md).

Build clean; --selftest passes. Deployed to both test folders. Held for next release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-07 23:21:18 +01:00
co-authored by Claude Opus 4.8
parent eaae76d015
commit 033edd776f
102 changed files with 4658 additions and 135 deletions
+3 -1
View File
@@ -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
+40 -24
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+361
View File
@@ -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": ""
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20
View File
@@ -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
}
}
}
Binary file not shown.
Binary file not shown.
+1312
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+65
View File
@@ -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
+164
View File
@@ -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=<uuid> addr=1.2.3.4:5555 count=2
event=client_endpoint_update client_id=<uuid> old=1.2.3.4:5555 new=1.2.3.4:6666
event=client_named client_id=<uuid> name=Andre
event=client_left client_id=<uuid> addr=... reason=bye
event=client_idle_expired client_id=<uuid> addr=...
event=lobby_full attempted_client_id=<uuid> 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).
+1
View File
@@ -0,0 +1 @@
server-v2.3
+141
View File
@@ -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."
+199
View File
@@ -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,^<srcdir>,remsound-server-v2.4,' <srcdir>
# 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.
+480
View File
@@ -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=<uuid> addr=<ip:port> name=<display>`
- `event=client_left client_id=<uuid>`
- `event=client_idle_expired client_id=<uuid>`
- `event=lobby_full attempted_client_id=<uuid> addr=<ip:port>`
- `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-<tag>.tar.gz`. If missing, log warning, exit 1.
b. curl the asset to /tmp/remsound-server-<tag>.tar.gz.
c. (Optional v2) Verify SHA256 against a second asset `remsound-server-<tag>.tar.gz.sha256`.
d. Extract to /tmp/remsound-server-<tag>/.
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-<tag>.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 13; client work is 46.
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 13 are server-side and can ship without any RemSound client release. Steps 46 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 46 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.
+13
View File
@@ -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
+358
View File
@@ -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 "$@"
+16
View File
@@ -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
+547
View File
@@ -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("<H", data, 6)[0]
sequence = struct.unpack_from("<I", data, 8)[0]
return pkt_type, stream_id, sequence
def parse_header_v2(data: bytes) -> 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("<H", data, 6)[0]
sequence = struct.unpack_from("<I", data, 8)[0]
client_id_bytes = bytes(data[V2_CLIENT_ID_OFFSET:V2_CLIENT_ID_OFFSET + V2_CLIENT_ID_LEN])
return pkt_type, stream_id, sequence, client_id_bytes
def _fmt_addr(addr: tuple[str, int]) -> 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("<H", header, 6, 0) # stream_id (unused)
struct.pack_into("<I", header, 8, 0) # sequence (unused)
header[V2_CLIENT_ID_OFFSET:V2_CLIENT_ID_OFFSET + V2_CLIENT_ID_LEN] = SERVER_CLIENT_ID_BYTES
payload = bytearray()
members = list(self.v2_clients.items())[:255] # 1-byte count
payload.append(len(members))
for cid, entry in members:
payload.extend(cid.bytes)
payload.extend(_encode_lobby_name(entry.display_name))
return bytes(header) + bytes(payload)
def _v2_broadcast_roster(self) -> 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("<H", header, 6, 0)
struct.pack_into("<I", header, 8, 0)
header[V2_CLIENT_ID_OFFSET:V2_CLIENT_ID_OFFSET + V2_CLIENT_ID_LEN] = SERVER_CLIENT_ID_BYTES
# Payload: 1 byte current count, 1 byte max count.
payload = bytes([len(self.v2_clients) & 0xFF, self.max_clients & 0xFF])
try:
self.sock.sendto(bytes(header) + payload, addr)
except OSError as e:
self.log.warning(
"event=send_failed proto=v2 reason=lobby_full to=%s err=%s",
_fmt_addr(addr), e,
)
self.log.info(
"event=lobby_full attempted_client_id=%s addr=%s count=%d max=%d",
attempted_client_id, _fmt_addr(addr),
len(self.v2_clients), self.max_clients,
)
self.stats.dropped_lobby_full += 1
def _v2_expire_idle(self, now: float) -> 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())
+14
View File
@@ -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
+123
View File
@@ -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
+63
View File
@@ -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."
+40 -24
View File
@@ -35,7 +35,7 @@ ul, ol { padding-left: 1.4em; }
<li><a href="#tab-connectivity">Connectivity tab</a></li>
<li><a href="#tab-io">Audio inputs and outputs tab</a></li>
<li><a href="#tab-profile">Audio profile tab</a></li>
<li><a href="#pan-eq">Pan and EQ tab</a></li>
<li><a href="#pan-eq">Volume, pan and EQ for peers tab</a></li>
<li><a href="#asio-vs-wasapi">ASIO and WASAPI</a></li>
<li><a href="#peers">Peers &mdash; finding and connecting</a></li>
<li><a href="#network">How the network works (LAN, WAN, Tailscale)</a></li>
@@ -219,7 +219,7 @@ ul, ol { padding-left: 1.4em; }
<ol>
<li>A <strong>menu bar</strong> at the top with four menus &mdash; <em>File</em>, <em>Record</em>, <em>Options</em> and <em>Help</em>. See <a href="#menu-bar">Menus</a>.</li>
<li>A <strong>row of tabs</strong> with three tabs &mdash; Connectivity, Audio inputs and outputs, Audio profile. A fourth tab, <strong>Pan and EQ</strong>, appears just before Audio profile if you switch it on in Preferences (it's off by default &mdash; see <a href="#pan-eq">Pan and EQ tab</a>). Each tab has its own Alt+letter shortcuts that only work when that tab is the one showing &mdash; so the same letter can do different things on different tabs without clashing.</li>
<li>A <strong>row of tabs</strong> with three tabs &mdash; Connectivity, Audio inputs and outputs, Audio profile. A fourth tab, <strong>Volume, pan and EQ for peers</strong>, appears just before Audio profile and is shown by default (you can hide it in Preferences &mdash; see <a href="#pan-eq">Volume, pan and EQ for peers tab</a>). Each tab has its own Alt+letter shortcuts that only work when that tab is the one showing &mdash; so the same letter can do different things on different tabs without clashing.</li>
<li>A <strong>status line</strong> 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.</li>
</ol>
@@ -227,7 +227,7 @@ ul, ol { padding-left: 1.4em; }
<tr><th>Tab</th><th>What it's for</th></tr>
<tr><td><strong>Connectivity</strong></td><td>Connected, discovered and remembered peers. Adding a peer by address. A connection status read-out.</td></tr>
<tr><td><strong>Audio inputs and outputs</strong></td><td>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 <em>(none)</em> hides them.</td></tr>
<tr><td><strong>Pan and EQ</strong> (optional)</td><td>Shape each connected peer's sound on its own &mdash; their volume, pan (left/right) and EQ. Hidden by default; tick &ldquo;Show the Pan and EQ tab&rdquo; on the General tab of Preferences to show it. See <a href="#pan-eq">Pan and EQ tab</a>.</td></tr>
<tr><td><strong>Volume, pan and EQ for peers</strong> (optional)</td><td>Shape each connected peer's sound on its own &mdash; their volume, pan (left/right) and EQ. Shown by default; untick &ldquo;Show the volume, pan and EQ for peers tab&rdquo; on the General tab of Preferences to hide it. See <a href="#pan-eq">Volume, pan and EQ for peers tab</a>.</td></tr>
<tr><td><strong>Audio profile</strong></td><td>Codec, packet size, lock-to-audio-clock, latency, continuous auto-tune, buffer smoothness, artefact sound. Split into an <em>Audio send parameters</em> group and an <em>Audio receive parameters</em> group.</td></tr>
</table>
@@ -311,7 +311,7 @@ ul, ol { padding-left: 1.4em; }
<tr><td><strong>Profile passwords&hellip;</strong></td><td>Alt+O, W</td><td>Lists every profile alongside its password, so you can view or change any of them in one place.</td></tr>
<tr><td><strong>Reset the default audio device prompt</strong></td><td>Alt+O, R</td><td>Brings back the &ldquo;use only the default audio device?&rdquo; question if you previously ticked &ldquo;Don't ask me this again&rdquo; on it. See <a href="#tab-io">Following the Windows default audio device</a>.</td></tr>
<tr><td><strong>Enable / Disable Realtek ASIO</strong></td><td>&mdash;</td><td>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).</td></tr>
<tr><td><strong>Preferences&hellip;</strong></td><td>Ctrl+P, or Alt+O, P</td><td>Opens the Preferences dialog, organised into five tabs (move between them with Ctrl+Tab, or the arrow keys when the tab names have focus): <strong>General</strong> &mdash; the profiles folder, accept remote volume commands, UPnP router opening, and &ldquo;Show the Pan and EQ tab&rdquo; (off by default; turns on the optional <a href="#pan-eq">Pan and EQ tab</a>); <strong>Audio cues</strong> &mdash; the cue list and its sounds (see <a href="#audio-cues">Audio cue sounds</a>); <strong>Startup behaviour</strong> &mdash; start minimised / with Windows / with a specific profile; <strong>Update settings</strong> &mdash; the update checks and install options; and <strong>Logging</strong> &mdash; enable logs, write logs now, and the log-folder housekeeping (see <a href="#logs">Logs and diagnostics</a>). Esc or the Close button dismisses it.</td></tr>
<tr><td><strong>Preferences&hellip;</strong></td><td>Ctrl+P, or Alt+O, P</td><td>Opens the Preferences dialog, organised into five tabs (move between them with Ctrl+Tab, or the arrow keys when the tab names have focus): <strong>General</strong> &mdash; the profiles folder, accept remote volume commands, UPnP router opening, and &ldquo;Show the volume, pan and EQ for peers tab&rdquo; (on by default; hides the <a href="#pan-eq">Volume, pan and EQ for peers tab</a> if you untick it); <strong>Audio cues</strong> &mdash; the cue list and its sounds (see <a href="#audio-cues">Audio cue sounds</a>); <strong>Startup behaviour</strong> &mdash; start minimised / with Windows / with a specific profile; <strong>Update settings</strong> &mdash; the update checks and install options; and <strong>Logging</strong> &mdash; enable logs, write logs now, and the log-folder housekeeping (see <a href="#logs">Logs and diagnostics</a>). Esc or the Close button dismisses it.</td></tr>
</table>
<h3>Help menu</h3>
@@ -349,7 +349,7 @@ ul, ol { padding-left: 1.4em; }
<tr><td><strong>Receive audio</strong></td><td>Alt+R</td><td>The master switch for receiving. When it's off, no sound plays out, no matter which output devices are ticked.</td></tr>
<tr><td><strong>WASAPI outputs for received sound</strong></td><td>Alt+3</td><td>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.</td></tr>
<tr><td><strong>ASIO outputs for received sound</strong></td><td>Alt+1</td><td>(Shown when an ASIO driver is chosen.) Tick which ASIO channel pairs should play the received sound.</td></tr>
<tr><td><strong>Set volume for all received audio</strong></td><td>Alt+V</td><td>A slider: the master volume for everything coming in. There is no separate volume per device or per person.</td></tr>
<tr><td><strong>Master receive volume</strong></td><td>Alt+V</td><td>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.</td></tr>
<tr><td><strong>Send my audio</strong></td><td>Alt+S</td><td>The master switch for sending.</td></tr>
<tr><td><strong>WASAPI outputs to send</strong></td><td>Alt+4</td><td>Tick which Windows output devices to capture from &mdash; this captures whatever is currently playing on those speakers and sends it.</td></tr>
<tr><td><strong>WASAPI inputs to send</strong></td><td>Alt+5</td><td>Tick which Windows input devices to capture (microphones, line-ins).</td></tr>
@@ -446,31 +446,44 @@ ul, ol { padding-left: 1.4em; }
<p>Most people only need to pick a codec and a smoothness level, and leave everything else at its default.</p>
<h2 id="pan-eq">9. Pan and EQ tab</h2>
<h2 id="pan-eq">9. Volume, pan and EQ for peers tab</h2>
<p>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 &mdash; for a jam session you might put the drummer over to the left, turn someone down a little, or brighten someone up.</p>
<p>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 &mdash; for a jam session you might put the drummer over to the left, turn someone down a little, or brighten someone up.</p>
<p>The Pan and EQ tab is <strong>optional and hidden by default</strong>. To turn it on, tick <strong>&ldquo;Show the Pan and EQ tab&rdquo;</strong> on the <strong>General</strong> tab of Preferences (Options &rarr; Preferences, or Ctrl+P). Once it's on, the tab appears just before the Audio profile tab.</p>
<p>The tab is <strong>shown by default</strong>. If you don't want it, untick <strong>&ldquo;Show the volume, pan and EQ for peers tab&rdquo;</strong> on the <strong>General</strong> tab of Preferences (Options &rarr; Preferences, or Ctrl+P). When it's on, the tab appears just before the Audio profile tab.</p>
<h3>Shaping one peer at a time</h3>
<h3>Turning it on, and choosing who to shape</h3>
<p>You always work on a single peer. First pick the person you want from the peer list, then use the controls below it &mdash; they all act on whoever you've selected. The tab, from top to bottom:</p>
<p>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 &mdash; a quick per-person bypass. The tab, from top to bottom:</p>
<table>
<tr><th>Control</th><th>What it does</th></tr>
<tr><td><strong>Enable EQ for peers</strong> (checkbox)</td><td>A master switch for the equaliser. When it's off, any EQ you've dialled in has no effect &mdash; but you can still set it up ready for when you turn it on.</td></tr>
<tr><td><strong>Enable pan for peers</strong> (checkbox)</td><td>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 &mdash; it has no switch and is always in effect (but at 100% it changes nothing).</td></tr>
<tr><td><strong>Peer to shape</strong> (list)</td><td>The peers you're currently connected to. Pick the one you want to work on. Everything below acts on the peer you select here.</td></tr>
<tr><td><strong>Enable volume, pan and EQ for all peers</strong> (checkbox)</td><td>The one master switch. When it's off, everyone passes through untouched &mdash; 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 &mdash; it starts unset).</td></tr>
<tr><td><strong>Peers</strong> (checklist)</td><td>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 &mdash; everything below acts on whoever the cursor is on.</td></tr>
<tr><td><strong>Volume</strong> (slider)</td><td>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.</td></tr>
<tr><td><strong>Pan</strong> (slider)</td><td>Leans the peer to the left or right. Centred by default. It keeps the peer's stereo sound &mdash; it never folds them down to mono.</td></tr>
<tr><td><strong>Set peer EQ to default</strong> (button)</td><td>Puts that peer's EQ sliders &mdash; both the 3-band and the 12-band &mdash; back to flat. It leaves the pan and volume alone.</td></tr>
<tr><td><strong>EQ mode</strong> (picker)</td><td>Two choices: <em>3 band basic EQ</em> or <em>12 band advanced EQ</em>. This chooses which set of band sliders you see below.</td></tr>
<tr><td><strong>EQ band sliders</strong></td><td>The sliders for whichever EQ mode is chosen. Each one runs from &minus;12 dB to +12 dB, with flat (no change) in the middle. The 3-band has <strong>Bass, Mids, Treble</strong>. The 12-band has <strong>31 Hz, 63 Hz, 80 Hz, 125 Hz, 250 Hz, 500 Hz, 1 kHz, 2 kHz, 4 kHz, 6 kHz, 8 kHz</strong> and <strong>16 kHz</strong>.</td></tr>
<tr><td><strong>Set peer EQ to default</strong> (button)</td><td>Puts that peer's EQ back to flat &mdash; all three modes at once (the 3-band, the 12-band and the parametric bands). It leaves the pan and volume alone.</td></tr>
<tr><td><strong>EQ mode</strong> (picker)</td><td>Three choices: <em>3 band simple EQ</em>, <em>12 band advanced graphic EQ</em> or <em>16 band parametric EQ</em>. This chooses which EQ controls you see below.</td></tr>
<tr><td><strong>EQ controls</strong></td><td>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.</td></tr>
</table>
<p>The two EQ modes are kept separate. Switching between the 3-band and the 12-band keeps each one's own settings &mdash; nothing carries across from one to the other.</p>
<h3>The three EQ modes</h3>
<p>Everything here updates in <strong>real time</strong> &mdash; you hear the change as you move a control &mdash; 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.</p>
<p><strong>3 band simple EQ</strong> and <strong>12 band advanced graphic EQ</strong> are graphic equalisers: a set of sliders at fixed frequencies, each running from &minus;12 dB to +12 dB with flat (no change) in the middle. The 3-band has <strong>Bass, Mids, Treble</strong>. The 12-band has <strong>31 Hz, 63 Hz, 80 Hz, 125 Hz, 250 Hz, 500 Hz, 1 kHz, 2 kHz, 4 kHz, 6 kHz, 8 kHz</strong> and <strong>16 kHz</strong>. Each slider reads its level out in words, for example &ldquo;plus 3 dB&rdquo;, &ldquo;minus 6 dB&rdquo; or &ldquo;flat&rdquo;.</p>
<p><strong>16 band parametric EQ</strong> lets you place your own bands wherever you want them, up to sixteen. Instead of fixed sliders you build a list of bands:</p>
<ul>
<li>Tab past the mode picker to the <strong>Add band</strong> button and press it. A small dialog opens with three boxes: a <strong>start frequency</strong>, an <strong>end frequency</strong> and a <strong>gain in dB</strong> (from &minus;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 <strong>OK</strong> to add it, or <strong>Cancel</strong> / <strong>Escape</strong> to drop it.</li>
<li>Back on the tab, tab to the <strong>Bands</strong> list to hear your bands, one per row, each read out as its range and level &mdash; for example &ldquo;200 Hz to 800 Hz, plus 3 dB&rdquo;. The list is sorted low to high, so the bass bands are at the top and the treble at the bottom.</li>
<li>To remove a band, land on it and press <strong>Delete</strong>, or use the <strong>Delete band</strong> 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.</li>
</ul>
<p>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.</p>
<p>The three modes are kept separate. Switching between them keeps each one's own settings &mdash; nothing carries across from one to another. Only the mode you've picked is the one you hear.</p>
<p>Everything here updates in <strong>real time</strong> &mdash; you hear the change as you move a control &mdash; 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 &ldquo;toggle everything&rdquo; 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.</p>
<h2 id="asio-vs-wasapi">10. ASIO and WASAPI</h2>
@@ -825,19 +838,21 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<tr><td>Alt+A</td><td>Focus Artefact sound type</td></tr>
</table>
<h3>Pan and EQ tab</h3>
<h3>Volume, pan and EQ for peers tab</h3>
<p>Only present when the Pan and EQ tab is switched on (tick &ldquo;Show the Pan and EQ tab&rdquo; on the General tab of Preferences). See <a href="#pan-eq">Pan and EQ tab</a>.</p>
<p>Present whenever the Volume, pan and EQ for peers tab is showing (it's shown by default; the toggle is &ldquo;Show the volume, pan and EQ for peers tab&rdquo; on the General tab of Preferences). See <a href="#pan-eq">Volume, pan and EQ for peers tab</a>.</p>
<table>
<tr><th>Key</th><th>Action</th></tr>
<tr><td>Alt+E</td><td>Toggle Enable EQ for peers</td></tr>
<tr><td>Alt+P</td><td>Toggle Enable pan for peers</td></tr>
<tr><td>Alt+U</td><td>Focus the peer to shape</td></tr>
<tr><td>Alt+E</td><td>Toggle Enable volume, pan and EQ for all peers</td></tr>
<tr><td>Alt+U</td><td>Focus the Peers checklist</td></tr>
<tr><td>Alt+L</td><td>Focus the Volume slider</td></tr>
<tr><td>Alt+N</td><td>Focus the Pan slider</td></tr>
<tr><td>Alt+Q</td><td>Set peer EQ to default</td></tr>
<tr><td>Alt+M</td><td>Focus the EQ mode picker</td></tr>
<tr><td>Alt+A</td><td>Add band (parametric EQ mode only)</td></tr>
<tr><td>Alt+B</td><td>Focus the Bands list (parametric EQ mode only)</td></tr>
<tr><td>Alt+D</td><td>Delete band (parametric EQ mode only)</td></tr>
</table>
<h3>File menu shortcuts (work from any tab)</h3>
@@ -895,6 +910,7 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<tr><td>Send Windows global volume down to peers</td><td>The same, but lowering.</td><td>Unset</td></tr>
<tr><td>Send Windows global mute toggle to peers</td><td>Tell every connected peer to toggle their Windows mute.</td><td>Unset</td></tr>
<tr><td>Speak the RemSound status information</td><td>Read the whole status line out loud through your screen reader &mdash; the connection time, how many peers you have, whether sound is flowing, and how healthy the link is &mdash; from anywhere, even with RemSound in the tray. Just for screen-reader users; see <a href="#speak-status">Hearing the status on demand</a> below.</td><td>Unset</td></tr>
<tr><td>Toggle volume, pan and EQ for all peers</td><td>Flip the one master switch on the <a href="#pan-eq">Volume, pan and EQ for peers tab</a> 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.</td><td>Unset</td></tr>
</table>
<p>You can change any of these to whatever combination you prefer. Each accepts modifiers (Ctrl, Shift, Alt) plus one ordinary key.</p>
@@ -1211,7 +1227,7 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
<table>
<tr><th>Control</th><th>What it does</th></tr>
<tr><td><strong>Split recording into separate tracks</strong> (tickbox)</td><td>Instead of one mixed file, a recording becomes a <em>folder</em> with one file per connected peer &mdash; each holding only that peer's sound &mdash; plus one file for your own send. Which of those files you get follows the Recording source choice below: <em>Received only</em> gives you the peer files; <em>Both</em> gives the peer files plus your own; <em>Sent only</em> gives just your own.</td></tr>
<tr><td><strong>Bypass pan and EQ when recording</strong> (tickbox)</td><td>Records the raw sound &mdash; before any volume, pan or EQ you've set on the <a href="#pan-eq">Pan and EQ tab</a> &mdash; 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.</td></tr>
<tr><td><strong>Bypass pan and EQ when recording</strong> (tickbox)</td><td>Records the raw sound &mdash; before any volume, pan or EQ you've set on the <a href="#pan-eq">Volume, pan and EQ for peers tab</a> &mdash; 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.</td></tr>
</table>
<table>
Binary file not shown.
+157
View File
@@ -0,0 +1,157 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>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.</summary>
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<ParametricBand?>? livePreview;
private bool accepted;
/// <summary>The band the user built, valid only when <see cref="Form.ShowDialog()"/> returned OK.</summary>
public ParametricBand Result => new()
{
StartHz = (float)startFreq.Value,
EndHz = (float)endFreq.Value,
GainDb = (float)gainDb.Value,
};
/// <param name="livePreview">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.</param>
public AddBandDialog(Action<ParametricBand?>? 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
}
}
+126
View File
@@ -0,0 +1,126 @@
using System.Drawing.Drawing2D;
using RemSound.Core;
namespace RemSound.App;
/// <summary>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.</summary>
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 = "";
}
/// <summary>Point the curve at a peer's shaping (or null for flat) and repaint.</summary>
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);
}
/// <summary>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.</summary>
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));
}
+275 -54
View File
@@ -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<string, PeerShaping> 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;
}
/// <summary>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 <see cref="PeerDspChain"/> / <see cref="PeerShaping"/>.</summary>
/// <summary>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 <see cref="PeerDspChain"/> /
/// <see cref="PeerShaping"/>.</summary>
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,
};
/// <summary>Fired when the user ticks/unticks a peer in the checklist — flips that peer's per-peer
/// bypass and re-applies its shaping immediately.</summary>
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)}";
}
/// <summary>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.</summary>
// 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";
}
/// <summary>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.</summary>
private bool ShapingActiveFor(string? key)
=> enableAllPeerShapingBox.Checked && (GetShaping(key)?.Enabled ?? true);
/// <summary>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.</summary>
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);
}
/// <summary>Pushes shaping for every currently-connected peer. Used when a master enable flips or a
/// <summary>Pushes shaping for every currently-connected peer. Used when the master switch flips or a
/// profile loads / the connected set changes.</summary>
private void ApplyAllPeerShaping()
{
var seen = new HashSet<string>();
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 ===
/// <summary>Populates <see cref="eqBandsPanel"/> with the parametric controls (Add band, the band
/// list, Delete band). The three controls are persistent members reused across rebuilds.</summary>
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();
}
/// <summary>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.</summary>
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<ParametricBand>(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<ParametricBandItem>().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)}";
}
/// <summary>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);
+29 -1
View File
@@ -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;
/// <summary>Optional log sink. MainForm wires this to <c>logFile.Event(...)</c> 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();
}
/// <summary>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;
/// <summary>Open the capture dialog, log what came back, and (on a successful capture)
/// run <paramref name="apply"/> 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)
{
+2 -2
View File
@@ -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,
};
+10 -5
View File
@@ -68,11 +68,12 @@ public sealed class AppConfig
/// already running quietly". Default false.</summary>
public bool StartMinimised { get; set; }
/// <summary>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 <see cref="Profile.PeerShaping"/>.</summary>
public bool ShowPanEqTab { get; set; }
/// <summary>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
/// <see cref="Profile.PeerShaping"/>.</summary>
public bool ShowPanEqTab { get; set; } = true;
/// <summary>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; }
/// <summary>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.</summary>
public HotkeyRecord? ToggleAllPeerShapingHotkey { get; set; }
/// <summary>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
+59 -6
View File
@@ -1,35 +1,64 @@
namespace RemSound.Core;
/// <summary>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
/// <summary>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.</summary>
public enum PeerEqMode
{
/// <summary>3-band tone control (bass / mids / treble). Display name: "3 band simple EQ".</summary>
Simple3Band = 0,
/// <summary>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.)</summary>
Advanced10Band = 1,
/// <summary>Up to 16 user-defined parametric bands, each a boost/cut across a start→end frequency
/// range. Display name: "16 band parametric EQ".</summary>
Parametric16Band = 2,
}
/// <summary>Per-peer pan + EQ, saved per profile (see <see cref="Profile.PeerShaping"/>). Keyed in
/// <summary>One user-defined parametric EQ band: a boost or cut spread across the range from
/// <see cref="StartHz"/> to <see cref="EndHz"/>. Realised as a peaking filter centred on the
/// geometric mean of the two frequencies, widened to span the range (see
/// <see cref="PeerEqBands.ParametricToPeaking"/>).</summary>
public sealed class ParametricBand
{
public float StartHz { get; set; }
public float EndHz { get; set; }
/// <summary>Gain in dB, -12..+12.</summary>
public float GainDb { get; set; }
public ParametricBand Clone() => new() { StartHz = StartHz, EndHz = EndHz, GainDb = GainDb };
}
/// <summary>Per-peer volume + pan + EQ, saved per profile (see <see cref="Profile.PeerShaping"/>). Keyed in
/// the profile by the same peer-entry string as <see cref="Profile.SelectedConnectedPeers"/>.</summary>
public sealed class PeerShaping
{
/// <summary>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.</summary>
public bool Enabled { get; set; } = true;
/// <summary>-1 (full left) .. 0 (centre) .. +1 (full right).</summary>
public float Pan { get; set; }
/// <summary>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.</summary>
/// on top of the global volume. 100% is transparent.</summary>
public float Volume { get; set; } = 1f;
/// <summary>Which EQ mode is currently active for this peer.</summary>
public PeerEqMode EqMode { get; set; } = PeerEqMode.Simple3Band;
/// <summary>Gains in dB (-12..+12) for the 3 simple bands, in the order of <see cref="PeerEqBands.Simple"/>
/// (bass, mids, treble). Kept independently of the advanced bands.</summary>
/// (bass, mids, treble). Kept independently of the other modes.</summary>
public float[] SimpleBandsDb { get; set; } = new float[3];
/// <summary>Gains in dB (-12..+12) for the 12 advanced graphic-EQ bands, in the order of
/// <see cref="PeerEqBands.Advanced"/>. Kept independently of the simple bands.</summary>
/// <see cref="PeerEqBands.Advanced"/>. Kept independently of the other modes.</summary>
public float[] AdvancedBandsDb { get; set; } = new float[12];
/// <summary>The user's parametric bands (up to <see cref="PeerEqBands.ParametricMaxBands"/>). Kept
/// independently of the two graphic modes. Empty = flat.</summary>
public List<ParametricBand> ParametricBands { get; set; } = new();
}
/// <summary>The fixed EQ band layouts. The user sets each band's GAIN; the centre frequencies are
@@ -44,6 +73,13 @@ public static class PeerEqBands
/// <summary>Maximum boost/cut for any band, in dB. Sliders run -12..+12.</summary>
public const float MaxGainDb = 12f;
/// <summary>Most parametric bands a peer can hold.</summary>
public const int ParametricMaxBands = 16;
/// <summary>Lowest / highest frequency a parametric band edge can be set to.</summary>
public const float ParametricMinHz = 20f;
public const float ParametricMaxHz = 20000f;
/// <summary>3-band "tone control": bass (low shelf), mids (peaking), treble (high shelf).</summary>
public static readonly (string Label, double Freq)[] Simple =
[
@@ -69,4 +105,21 @@ public static class PeerEqBands
("8 kHz", 8000.0),
("16 kHz", 16000.0),
];
/// <summary>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.</summary>
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);
}
}
+8 -5
View File
@@ -183,12 +183,15 @@ public sealed class Profile
/// (the connection simply waits or drops rather than moving). Off by default. (#17)</summary>
public bool LockPeerAddresses { get; set; }
// === Per-peer pan and EQ (jam mixing) ===
/// <summary>Master switch for this profile: apply each connected peer's saved PAN. Off by default.
/// The pan values themselves live in <see cref="PeerShaping"/>.</summary>
public bool EnablePanForPeers { get; set; }
// === Per-peer volume, pan and EQ (jam mixing) ===
/// <summary>Single master switch for this profile: apply each connected peer's saved VOLUME, PAN and
/// EQ. Off by default. The values themselves live in <see cref="PeerShaping"/>; a peer can also be
/// bypassed individually via <see cref="RemSound.Core.PeerShaping.Enabled"/>.</summary>
public bool EnableAllPeerShaping { get; set; }
/// <summary>Master switch for this profile: apply each connected peer's saved EQ. Off by default.</summary>
/// <summary>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.</summary>
public bool EnablePanForPeers { get; set; }
public bool EnableEqForPeers { get; set; }
/// <summary>Per-peer pan + EQ, keyed by the SAME peer-entry string used in

Some files were not shown because too many files have changed in this diff Show More