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:
co-authored by
Claude Opus 4.8
parent
eaae76d015
commit
033edd776f
+3
-1
@@ -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
|
||||
|
||||
@@ -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.
@@ -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.
@@ -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
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.
@@ -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
|
||||
@@ -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).
|
||||
@@ -0,0 +1 @@
|
||||
server-v2.3
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
@@ -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 1–3; client work is 4–6.
|
||||
|
||||
1. **v2 wire format added to client side, still emits v1 by default.** No behaviour change. Just makes the new header reading / writing code paths exist. Backward-compatible with existing relays.
|
||||
2. **New `remsound-lobby.py` ships as `server-v2.0`.** Handles v1 packets exactly like today's relay (two-slot pairing); handles v2 packets via the lobby logic. Initial deployment: install on onj.me alongside / replacing the current relay. Existing v1 clients keep working pairwise.
|
||||
3. **Auto-updater bundle goes live.** Once steps 1 and 2 are in place, future server changes roll out without manual deployment.
|
||||
4. **Client adds CLIENT_ID generation + persistence.** New UUID stamped on every outbound v2 packet. Server now sees the same client by ID even if endpoint changes.
|
||||
5. **Client receiver re-keys sessions on CLIENT_ID.** Internal change. Lobby connections become a real first-class thing in the UI.
|
||||
6. **Client UI: lobby tab / lobby roster integration.** Replace per-pair manual peer entry with a "connect to lobby" affordance. LAN discovery still works in parallel for non-lobby setups.
|
||||
|
||||
Each phase is a separate commit (and release where appropriate). Steps 1–3 are server-side and can ship without any RemSound client release. Steps 4–6 are client-side; they should ship in a single client release (probably `v1.5` or `v2.0` depending on how disruptive the wire change feels).
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### Server-side
|
||||
|
||||
- **`smoke-test.sh`** runs the basic install-check (listener bound, service active, log present, updater timer enabled).
|
||||
- **2-client v1 pair test**: existing test from the current bundle. Should still pass — proves backward compat.
|
||||
- **3-client v2 lobby test**: bring up three RemSound instances (or three test scripts that mimic v2 packets), all dial the relay, confirm each receives the other two's audio.
|
||||
- **Lobby-full test**: 11th client receives `LobbyFull`. Server doesn't crash.
|
||||
- **Idle eviction test**: stop one client, wait 60 s, confirm the other clients receive an updated roster.
|
||||
- **Auto-update dry-run**: tag a no-op server release, watch the timer fire, confirm the relay picks it up, restarts, and the new version shows in `/etc/remsound-relay/version`.
|
||||
|
||||
### Client-side (once steps 4–6 land)
|
||||
|
||||
- Two instances behind the same NAT both connect to the same lobby: each sees the other and the remote peer in the lobby roster.
|
||||
- Network rebinding (Wi-Fi → Ethernet): client session stays alive; endpoint update is picked up by the server.
|
||||
- Lobby-full: clean UI message.
|
||||
- Mixed lobby of v2 clients only (initially we won't support mixing v1 and v2 in one lobby — it's a separate work stream if ever needed).
|
||||
|
||||
### Production canary
|
||||
|
||||
When `server-v2.0` is ready, ship it first to one of the relays (the Pi or onj.me, your call), watch logs for a day or two, then deploy to the other. Auto-update + atomic rollback means even a bad release doesn't take both hosts offline simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions
|
||||
|
||||
These should be answered before code starts but aren't blocking for design review.
|
||||
|
||||
1. **Display names**: do we want them at all in v2.0, or defer to v2.1? They're nice for UI roster display but not load-bearing for routing. **Suggestion: defer.** Client sends `LobbyHello` with display name = profile name; server treats missing / empty display names as "Unknown" in the roster.
|
||||
2. **Authentication**: do we need a shared secret to join a lobby on onj.me? **Suggestion: not for v2.0.** Anyone who knows the address can connect. If unwanted-strangers becomes a problem, add a `LobbyAuth(secret)` packet type in a later release.
|
||||
3. **IPv6**: the existing relay binds IPv4 only. For Andre's full server it'd be reasonable to bind both. **Suggestion: optional dual-stack via `--bind6`. Default IPv4 to match existing behaviour.**
|
||||
4. **Metrics endpoint**: would a Prometheus-style `/metrics` HTTP endpoint be useful for Andre? **Suggestion: not in v2.0. The per-minute stats line in the log file is enough until someone asks.**
|
||||
5. **Recording at the server**: a server-side recording feature would be powerful (capture all lobby audio for later playback / archive) but adds the kind of complexity the rest of this design carefully avoids. **Suggestion: defer indefinitely.**
|
||||
6. **Renegotiating CLIENT_ID**: if someone wants to wipe their identity (privacy / fresh start), the simplest answer is "delete the line from `remsound.config.json` and restart". No protocol-level renegotiation needed.
|
||||
|
||||
---
|
||||
|
||||
## 12. Quick reference for Andre
|
||||
|
||||
If you're reading this to set up your Linux box once the v2.0 release is out:
|
||||
|
||||
```
|
||||
# 1. Download and extract the latest server bundle.
|
||||
curl -L -o /tmp/remsound-server.tar.gz \
|
||||
https://github.com/Ednunp/RemSound/releases/download/server-v2.0/remsound-server-v2.0.tar.gz
|
||||
tar xzf /tmp/remsound-server.tar.gz -C /tmp
|
||||
cd /tmp/remsound-server-v2.0
|
||||
|
||||
# 2. Install. This sets up the relay AND the auto-updater.
|
||||
sudo ./install.sh
|
||||
|
||||
# 3. Open UDP 47830 in the firewall.
|
||||
sudo ufw allow 47830/udp
|
||||
# (or whatever your firewall is)
|
||||
|
||||
# 4. Confirm it's alive.
|
||||
sudo ./smoke-test.sh
|
||||
```
|
||||
|
||||
After that you can forget about it. Future updates roll out automatically. If you want to pin a version, `sudo systemctl disable --now remsound-relay-update.timer`. If you want to remove it entirely, `sudo ./uninstall.sh`.
|
||||
|
||||
Log file is `/var/log/remsound-relay.log` for the relay itself, `/var/log/remsound-relay-update.log` for the update history. Both rotate via the system's logrotate defaults.
|
||||
|
||||
---
|
||||
|
||||
## 13. Status
|
||||
|
||||
This is a design + handover document, not the implementation. None of the v2 code exists yet. When ready to build:
|
||||
|
||||
- `remsound-lobby.py` (~250 lines Python) — the new lobby relay.
|
||||
- `remsound-relay-update.sh` (~150 lines Bash) — the updater.
|
||||
- Two systemd units (~30 lines total) — service + timer for the updater.
|
||||
- `install.sh` / `uninstall.sh` / `smoke-test.sh` updates.
|
||||
- Wire format v2 in `RemSound.Core.RemPacket` (~100 lines C# delta).
|
||||
- Per-client UUID persistence + heartbeat / session refactor in the client (~few hundred lines).
|
||||
- Client UI for the lobby tab (later phase, optional in v2.0 release).
|
||||
|
||||
Total scope: a couple of focused days for the server bundle, several days for the client refactor + UI. Server-side ships first and runs alongside the existing two-slot relay without breaking it.
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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 — 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 — <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 — 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 — 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 — so the same letter can do different things on different tabs without clashing.</li>
|
||||
<li>A <strong>row of tabs</strong> with three tabs — 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 — 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 — 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 — 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 <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 — 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 <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…</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 “use only the default audio device?” question if you previously ticked “Don't ask me this again” 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>—</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…</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> — the profiles folder, accept remote volume commands, UPnP router opening, and “Show the Pan and EQ tab” (off by default; turns on the optional <a href="#pan-eq">Pan and EQ tab</a>); <strong>Audio cues</strong> — the cue list and its sounds (see <a href="#audio-cues">Audio cue sounds</a>); <strong>Startup behaviour</strong> — start minimised / with Windows / with a specific profile; <strong>Update settings</strong> — the update checks and install options; and <strong>Logging</strong> — 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…</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> — 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 <a href="#pan-eq">Volume, pan and EQ for peers tab</a> if you untick it); <strong>Audio cues</strong> — the cue list and its sounds (see <a href="#audio-cues">Audio cue sounds</a>); <strong>Startup behaviour</strong> — start minimised / with Windows / with a specific profile; <strong>Update settings</strong> — the update checks and install options; and <strong>Logging</strong> — 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 — 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 — 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 — 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>“Show the Pan and EQ tab”</strong> on the <strong>General</strong> tab of Preferences (Options → 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>“Show the volume, pan and EQ for peers tab”</strong> on the <strong>General</strong> tab of Preferences (Options → 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 — 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 — 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 — 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 — 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 — 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).</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 — 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 — 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 — both the 3-band and the 12-band — 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 −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 — 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 — nothing carries across from one to the other.</p>
|
||||
<h3>The three EQ modes</h3>
|
||||
|
||||
<p>Everything here updates in <strong>real time</strong> — 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.</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 −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 “plus 3 dB”, “minus 6 dB” or “flat”.</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 −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 — 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.</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 — 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> — 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.</p>
|
||||
|
||||
<h2 id="asio-vs-wasapi">10. ASIO and WASAPI</h2>
|
||||
|
||||
@@ -825,19 +838,21 @@ Audient USB Audio ASIO Driver — 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 “Show the Pan and EQ tab” 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 “Show the volume, pan and EQ for peers tab” 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 — 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 — 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 <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 — 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: <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 — before any volume, pan or EQ you've set on the <a href="#pan-eq">Pan and EQ tab</a> — 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 — before any volume, pan or EQ you've set on the <a href="#pan-eq">Volume, pan and EQ for peers tab</a> — 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.
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
+267
-46
@@ -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;
|
||||
loadingPanEqControls = true;
|
||||
try
|
||||
{
|
||||
panEqPeerList.BeginUpdate();
|
||||
panEqPeerList.Items.Clear();
|
||||
int idx = -1;
|
||||
foreach (var d in desired)
|
||||
{
|
||||
int i = panEqPeerList.Items.Add(d);
|
||||
// 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();
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user