diff --git a/MANUAL.md b/MANUAL.md index 894d521..3da5566 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -294,12 +294,20 @@ This is where you manage peers and reach the logging options. The controls on th Control| Shortcut| What it does ---|---|--- **Connected peers**| Alt+C| The people you currently have sound flowing with. Unticking a row disconnects that peer. +**Peer details**| Alt+E| A read-only box describing whichever connected peer you're on in the list above. Arrow through it to read: their name, their machine name, their IP address, how long you've been connected, the link health and ping, what they're sending (how many devices, on WASAPI or ASIO, at what sample rate and codec), and whether they're receiving your audio. The device and WASAPI/ASIO detail only shows while you're actually receiving that peer. +**Rename peer**| Alt+M| Give the highlighted peer a friendly name of your choosing. It opens a box with the name and a **Clear custom name** button. The name sticks to that machine for good — across restarts, IP changes and networks — and shows everywhere that peer appears: the lists here, the volume, pan and EQ for peers tab, the status line and split-recording filenames. See below. **Discovered peers**| Alt+D| People RemSound has heard from in the last few seconds. Tick someone to connect to them. **Remembered peers**| Alt+R| People you've connected to before, or added by address. This list is kept between sessions. Tick someone to reconnect. **Add peer by IP**| Alt+A| Opens a small box where you type an address or computer name. It adds that peer to the remembered list and connects. **Lock to these exact peer addresses**| Alt+L| When ticked, this profile uses only the exact addresses you set and never follows the other computer by name or switches to a different address — even if the address stops working. Off by default, saved with the profile. See Locking a profile to one exact address. **Connection status**| Alt+S| A read-only box of text that sums up everything happening right now — how long you've been connected, how many peers you have, how much sound is flowing each way, and the connection health of each peer. Open it to read the current connection status. +### Giving a peer a friendly name + +Highlight a peer in the **Connected peers** list and press **Rename peer (Alt+M)** to call them whatever you like — “Andre's desktop” instead of “ANDRE-DESKTOP”. The name is tied to that _machine_ , not to its address, so it survives their restarting RemSound, their address changing, and their reaching you on a different network (local network one day, Tailscale the next). Once set, it replaces the machine name everywhere that peer shows up — both peer lists, the volume, pan and EQ for peers list, the connection status, and the per-peer files a split recording makes. + +In the rename box, type the name and press OK, or press **Clear custom name (Alt+C)** to drop back to the machine name. (Leaving the box empty and pressing OK does the same.) The names are kept per machine you're using RemSound on, and apply in every profile. One case to know about: a peer you added purely by address that never announces a name has no machine name to pin to, so its friendly name is tied to the address instead and would need re-setting if that address changes. + ## 7. Audio inputs and outputs tab This tab controls everything to do with which sound devices are involved. The ASIO driver picker at the top decides whether ASIO is being used at all. The Receive side and the Send side each have their own master checkbox and their own device lists. @@ -757,6 +765,8 @@ Each tab has its own Alt+letter shortcuts. The same letter can do different thin Key| Action ---|--- Alt+C| Focus the Connected peers list +Alt+E| Focus the Peer details box (for the highlighted connected peer) +Alt+M| Rename the highlighted connected peer Alt+D| Focus the Discovered peers list Alt+R| Focus the Remembered peers list Alt+A| Add peer by IP diff --git a/readme.html b/readme.html index c7c2e72..59f21a6 100644 --- a/readme.html +++ b/readme.html @@ -332,6 +332,8 @@ ul, ol { padding-left: 1.4em; } + + @@ -339,6 +341,12 @@ ul, ol { padding-left: 1.4em; }
ControlShortcutWhat it does
Connected peersAlt+CThe people you currently have sound flowing with. Unticking a row disconnects that peer.
Peer detailsAlt+EA read-only box describing whichever connected peer you're on in the list above. Arrow through it to read: their name, their machine name, their IP address, how long you've been connected, the link health and ping, what they're sending (how many devices, on WASAPI or ASIO, at what sample rate and codec), and whether they're receiving your audio. The device and WASAPI/ASIO detail only shows while you're actually receiving that peer.
Rename peerAlt+MGive the highlighted peer a friendly name of your choosing. It opens a box with the name and a Clear custom name button. The name sticks to that machine for good — across restarts, IP changes and networks — and shows everywhere that peer appears: the lists here, the volume, pan and EQ for peers tab, the status line and split-recording filenames. See below.
Discovered peersAlt+DPeople RemSound has heard from in the last few seconds. Tick someone to connect to them.
Remembered peersAlt+RPeople you've connected to before, or added by address. This list is kept between sessions. Tick someone to reconnect.
Add peer by IPAlt+AOpens a small box where you type an address or computer name. It adds that peer to the remembered list and connects.
Connection statusAlt+SA read-only box of text that sums up everything happening right now — how long you've been connected, how many peers you have, how much sound is flowing each way, and the connection health of each peer. Open it to read the current connection status.
+

Giving a peer a friendly name

+ +

Highlight a peer in the Connected peers list and press Rename peer (Alt+M) to call them whatever you like — “Andre's desktop” instead of “ANDRE-DESKTOP”. The name is tied to that machine, not to its address, so it survives their restarting RemSound, their address changing, and their reaching you on a different network (local network one day, Tailscale the next). Once set, it replaces the machine name everywhere that peer shows up — both peer lists, the volume, pan and EQ for peers list, the connection status, and the per-peer files a split recording makes.

+ +

In the rename box, type the name and press OK, or press Clear custom name (Alt+C) to drop back to the machine name. (Leaving the box empty and pressing OK does the same.) The names are kept per machine you're using RemSound on, and apply in every profile. One case to know about: a peer you added purely by address that never announces a name has no machine name to pin to, so its friendly name is tied to the address instead and would need re-setting if that address changes.

+

7. Audio inputs and outputs tab

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

@@ -797,6 +805,8 @@ Audient USB Audio ASIO Driver — Pair 3 (channels 5/6): Loop-back 1 (L) / L + + diff --git a/src/RemSound.App/AddBandDialog.cs b/src/RemSound.App/AddBandDialog.cs index 58bd885..7d12279 100644 --- a/src/RemSound.App/AddBandDialog.cs +++ b/src/RemSound.App/AddBandDialog.cs @@ -1,22 +1,25 @@ +using System.Globalization; using RemSound.Core; namespace RemSound.App; /// Modal dialog for adding one parametric EQ band. The user sets a start frequency, an end /// frequency and a gain; all three are spin-or-type boxes that refuse non-numbers and clamp to range. -/// While the dialog is open it previews the in-progress band live (so the peer's sound changes as the -/// values move); OK keeps the band, Cancel/Escape drops it and reverts the preview. +/// The two frequency boxes start EMPTY (no prepopulated value to mislead the user); the gain defaults +/// to a gentle boost. 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. internal sealed class AddBandDialog : Form { private readonly NumericUpDown startFreq = new() { Minimum = (decimal)PeerEqBands.ParametricMinHz, Maximum = (decimal)PeerEqBands.ParametricMaxHz, - Value = 200, + Value = (decimal)PeerEqBands.ParametricMinHz, Increment = 10, DecimalPlaces = 0, Width = 120, TextAlign = HorizontalAlignment.Right, + TabIndex = 0, AccessibleName = "Start frequency in Hertz (Alt+S)", }; @@ -24,11 +27,12 @@ internal sealed class AddBandDialog : Form { Minimum = (decimal)PeerEqBands.ParametricMinHz, Maximum = (decimal)PeerEqBands.ParametricMaxHz, - Value = 2000, + Value = (decimal)PeerEqBands.ParametricMinHz, Increment = 10, DecimalPlaces = 0, Width = 120, TextAlign = HorizontalAlignment.Right, + TabIndex = 1, AccessibleName = "End frequency in Hertz (Alt+E)", }; @@ -37,27 +41,24 @@ internal sealed class AddBandDialog : Form Minimum = -(decimal)PeerEqBands.MaxGainDb, Maximum = (decimal)PeerEqBands.MaxGainDb, Value = 3, - Increment = 1, - DecimalPlaces = 0, + Increment = 0.5m, // allow half-dB steps; typing any value like 1.5 works too + DecimalPlaces = 1, Width = 120, TextAlign = HorizontalAlignment.Right, + TabIndex = 2, AccessibleName = "Gain in dB (Alt+G)", }; private readonly Action? livePreview; + private ParametricBand? resultBand; private bool accepted; /// The band the user built, valid only when returned OK. - public ParametricBand Result => new() - { - StartHz = (float)startFreq.Value, - EndHz = (float)endFreq.Value, - GainDb = (float)gainDb.Value, - }; + public ParametricBand Result => resultBand ?? new ParametricBand(); /// 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. + /// can apply it to the peer in real time, and with null when the band is incomplete or the dialog + /// is cancelled/closed so the caller reverts to the saved shaping. public AddBandDialog(Action? livePreview = null) { this.livePreview = livePreview; @@ -84,29 +85,39 @@ internal sealed class AddBandDialog : Form 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 }; + var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.None, TabIndex = 0 }; + var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel, TabIndex = 1 }; okButton.Click += (_, _) => TryAccept(); + // OK before Cancel, in both tab order and left-to-right layout. var buttonRow = new FlowLayoutPanel { Dock = DockStyle.Fill, - FlowDirection = FlowDirection.RightToLeft, + FlowDirection = FlowDirection.LeftToRight, AutoSize = true, + TabIndex = 3, }; - buttonRow.Controls.Add(cancelButton); buttonRow.Controls.Add(okButton); + buttonRow.Controls.Add(cancelButton); grid.Controls.Add(buttonRow, 1, 3); Controls.Add(grid); AcceptButton = okButton; CancelButton = cancelButton; - startFreq.ValueChanged += (_, _) => Preview(); - endFreq.ValueChanged += (_, _) => Preview(); - gainDb.ValueChanged += (_, _) => Preview(); + foreach (var box in new[] { startFreq, endFreq, gainDb }) + { + box.ValueChanged += (_, _) => Preview(); + box.TextChanged += (_, _) => Preview(); + } - Shown += (_, _) => { startFreq.Focus(); Preview(); }; + Shown += (_, _) => + { + // Blank the two frequency boxes so nothing is prepopulated; the user must enter both. + startFreq.Text = ""; + endFreq.Text = ""; + startFreq.Focus(); + }; } // The mnemonic hint is embedded in each NumericUpDown's AccessibleName; the label carries the @@ -114,41 +125,72 @@ internal sealed class AddBandDialog : Form 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() + private static bool TryReadHz(NumericUpDown box, out float hz) { - StartHz = (float)startFreq.Value, - EndHz = (float)endFreq.Value, - GainDb = (float)gainDb.Value, - }; + hz = 0f; + var t = box.Text.Trim(); + if (t.Length == 0) return false; + if (!float.TryParse(t, NumberStyles.Float, CultureInfo.CurrentCulture, out hz)) return false; + return hz >= PeerEqBands.ParametricMinHz && hz <= PeerEqBands.ParametricMaxHz; + } - private void Preview() => livePreview?.Invoke(Current()); + private bool TryReadDb(out float db) + { + db = 0f; + var t = gainDb.Text.Trim(); + if (t.Length == 0) return false; + if (!float.TryParse(t, NumberStyles.Float, CultureInfo.CurrentCulture, out db)) return false; + db = Math.Clamp(db, -PeerEqBands.MaxGainDb, PeerEqBands.MaxGainDb); + return true; + } + + private void Preview() + { + if (TryReadHz(startFreq, out float s) && TryReadHz(endFreq, out float e) && e > s) + livePreview?.Invoke(new ParametricBand { StartHz = s, EndHz = e, GainDb = TryReadDb(out float d) ? d : 0f }); + else + livePreview?.Invoke(null); // incomplete / invalid → nothing to preview + } private void TryAccept() { - if (endFreq.Value <= startFreq.Value) + if (!TryReadHz(startFreq, out float s) || !TryReadHz(endFreq, out float e)) { - 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); + Warn("Enter both frequencies", + $"Enter a start and an end frequency, each between {PeerEqBands.ParametricMinHz:0} and {PeerEqBands.ParametricMaxHz:0} Hz."); + startFreq.Focus(); + return; + } + if (e <= s) + { + Warn("End frequency must be higher than start", + $"The end frequency ({e:0} Hz) must be higher than the start frequency ({s:0} Hz). Adjust one of them and try again."); endFreq.Focus(); return; } + resultBand = new ParametricBand { StartHz = s, EndHz = e, GainDb = TryReadDb(out float d) ? d : 0f }; accepted = true; DialogResult = DialogResult.OK; Close(); } + private void Warn(string heading, string text) + { + var page = new TaskDialogPage + { + Caption = "Add EQ band", + Heading = heading, + Text = text, + Icon = TaskDialogIcon.Warning, + Buttons = { TaskDialogButton.OK }, + }; + TaskDialog.ShowDialog(this, page); + } + protected override void OnFormClosing(FormClosingEventArgs e) { base.OnFormClosing(e); diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 9063d71..37b0faf 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -268,6 +268,9 @@ public sealed class MainForm : Form private readonly CheckedListBox rememberedPeersList = new() { CheckOnClick = true, Width = 430, Height = 90, AccessibleName = "Remembered peers (Alt+R)" }; private readonly Label rememberedPeersStatus = new() { AutoSize = true, Text = "No remembered peer selected." }; private readonly Button manualAddButton = new() { Text = "Add peer by IP (Alt+&A)", AutoSize = true, AccessibleName = "Add peer by IP" }; + // Read-only details for the peer highlighted in the connected list, and a button to give it a name. + private readonly TextBox peerDetailsBox = new() { Multiline = true, ReadOnly = true, Width = 430, Height = 132, ScrollBars = ScrollBars.Vertical, AccessibleName = "Peer details (Alt+E)" }; + private readonly Button renamePeerButton = new() { Text = "Rena&me peer (Alt+M)", AutoSize = true, AccessibleName = "Rename peer" }; // Per-profile "pin to exact addresses" toggle (#17). When ticked, RefreshKnownPeers skips the // discovered-peer merge and the address-follow, so the profile's peers stay exactly as the user set. private readonly AccessibleCheckBox lockPeerAddressesBox = new() @@ -419,6 +422,12 @@ public sealed class MainForm : Form // Display labels for selected peers so we can render them in the dialog list even when // discovery has temporarily lost sight of them ("Foo (192.168.1.5) — offline"). private readonly Dictionary selectedPeerLabels = []; + // Custom friendly names, keyed by peer identity (machine name, else address). Loaded from AppConfig + // (machine-wide) at startup and mirrored back on rename. Resolved to display names everywhere a peer + // shows — connected/discovered lists, the volume/pan/EQ list, the status line, split recordings. + private Dictionary peerFriendlyNames = new(StringComparer.OrdinalIgnoreCase); + // When each connected peer (by per-run InstanceId) first went healthy — for the "connected for" line. + private readonly Dictionary peerConnectedSinceUtc = []; // Anti-thrash state for the discovery-driven endpoint follow (see the peer-rebuild loop). A peer // reachable at two addresses at once (a VPN address AND a LAN address, say) announces from both, @@ -842,6 +851,11 @@ public sealed class MainForm : Form .Select(kv => (kv.Value.Address, selectedPeerLabels.GetValueOrDefault(kv.Key, kv.Value.Address.ToString()))) .ToList(); + // Load the machine-wide friendly-name book and make every peer list resolve display names + // through it. Set before any list is built so names show from the first render. + peerFriendlyNames = new Dictionary(AppConfig.Load().PeerFriendlyNames ?? new(), StringComparer.OrdinalIgnoreCase); + PeerListItem.DisplayNameProvider = ResolvePeerDisplayName; + // --- Set accessibility names --- // For these four controls the keyboard shortcut is included explicitly in both the // visible label (set in BuildLayout) and the AccessibleName, instead of relying on the @@ -2974,13 +2988,24 @@ public sealed class MainForm : Form // Preferences dialog (File → Preferences, Ctrl+P) as the last two items. // === Layout === - // 6 rows: 0–2 the three peer lists, 3 manual-add, 4 the lock-to-fixed-addresses toggle, 5 status. - panel.RowCount = 6; + // 8 rows: 0 connected peers, 1 the details box + 2 rename button (both about the highlighted + // connected peer), 3–4 discovered & remembered lists, 5 manual-add, 6 the lock toggle, 7 status. + panel.RowCount = 8; FormLayoutRows.AddCheckedListRow(panel, 0, "Connected peers (Alt+&C)", connectedPeersList, connectedPeersStatus, FocusListControl); - FormLayoutRows.AddCheckedListRow(panel, 1, "Discovered peers (Alt+&D)", discoveredPeersList, discoveredPeersStatus, FocusListControl); - FormLayoutRows.AddCheckedListRow(panel, 2, "Remembered peers (Alt+&R)", rememberedPeersList, rememberedPeersStatus, FocusListControl); - panel.Controls.Add(new Label { Text = "Manual peer", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 3); - panel.Controls.Add(manualAddButton, 1, 3); + + // Details of, and a rename for, the peer highlighted in the connected list above. + var detailsLabel = new MnemonicLabel { Text = "Peer d&etails (Alt+E)", AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = peerDetailsBox }; + detailsLabel.Click += (_, _) => peerDetailsBox.Focus(); + panel.Controls.Add(detailsLabel, 0, 1); + panel.Controls.Add(peerDetailsBox, 1, 1); + renamePeerButton.Click += (_, _) => OnRenamePeer(); + panel.Controls.Add(renamePeerButton, 1, 2); + connectedPeersList.SelectedIndexChanged += (_, _) => UpdatePeerDetails(); + + FormLayoutRows.AddCheckedListRow(panel, 3, "Discovered peers (Alt+&D)", discoveredPeersList, discoveredPeersStatus, FocusListControl); + FormLayoutRows.AddCheckedListRow(panel, 4, "Remembered peers (Alt+&R)", rememberedPeersList, rememberedPeersStatus, FocusListControl); + panel.Controls.Add(new Label { Text = "Manual peer", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 5); + panel.Controls.Add(manualAddButton, 1, 5); lockPeerAddressesBox.Checked = settings.LoadLockPeerAddresses(); lockPeerAddressesBox.CheckedChanged += (_, _) => @@ -2989,14 +3014,15 @@ public sealed class MainForm : Form MarkProfileDirty(); logFile.Event($"lock peer addresses: {(lockPeerAddressesBox.Checked ? "on" : "off")}"); }; - panel.Controls.Add(lockPeerAddressesBox, 0, 4); + panel.Controls.Add(lockPeerAddressesBox, 0, 6); panel.SetColumnSpan(lockPeerAddressesBox, 2); // Connection status readout — last row, tab-into-able. var statusLabel = new MnemonicLabel { Text = "Connection status (Alt+&S)", AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = statusReadout }; statusLabel.Click += (_, _) => statusReadout.Focus(); - panel.Controls.Add(statusLabel, 0, 5); - panel.Controls.Add(statusReadout, 1, 5); + panel.Controls.Add(statusLabel, 0, 7); + panel.Controls.Add(statusReadout, 1, 7); + UpdatePeerDetails(); // Initial render so the box has content the moment the user tabs into it. RefreshStatusReadout(); @@ -3495,12 +3521,19 @@ public sealed class MainForm : Form // 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.) + // The graphic sliders read whole dB; the parametric list keeps up to one decimal (e.g. 1.5 dB). 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"; } + private static string FormatGainDbPrecise(float db) + { + if (MathF.Abs(db) < 0.05f) return "flat"; + return db > 0 ? $"plus {db:0.#} dB" : $"minus {MathF.Abs(db):0.#} dB"; + } + /// Whether a given peer is shaped right now: the profile-wide master switch AND that peer's /// own tick (per-peer bypass) both have to be on. private bool ShapingActiveFor(string? key) @@ -3649,7 +3682,7 @@ public sealed class MainForm : Form 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)}"; + public override string ToString() => $"{Band.StartHz:0} Hz to {Band.EndHz:0} Hz, {FormatGainDbPrecise(Band.GainDb)}"; } /// Send-side controls: codec + packet size on row 0, lock-to-audio-clock on @@ -4189,11 +4222,145 @@ public sealed class MainForm : Form ? RoundToFive(rtt) : null; + // Keep the friendly-name label fresh for the pan/EQ list, status line and recordings, and + // track when this peer first went healthy for the "connected for" line. + selectedPeerLabels[item.Peer.InstanceId] = ResolvePeerDisplayName(item.Peer); + if (isHealthy) { if (!peerConnectedSinceUtc.ContainsKey(item.Peer.InstanceId)) peerConnectedSinceUtc[item.Peer.InstanceId] = DateTime.UtcNow; } + else peerConnectedSinceUtc.Remove(item.Peer.InstanceId); + if (item.ToString() != prevText) { connectedPeersList.RefreshItemPublic(i); } } + UpdatePeerDetails(); + } + + // === Peer identity, friendly names, and the details box === + + /// The stable key a friendly name is stored under: the peer's machine name when it's a real + /// name, otherwise its address (the manual-by-IP case, where Name equals the address string). + private static string PeerIdentityKey(PeerAnnouncement peer) + { + var addr = peer.Address.ToString(); + return string.IsNullOrWhiteSpace(peer.Name) || peer.Name == addr ? addr : peer.Name; + } + + /// The name to show for a peer: its custom friendly name if set, else its machine name + /// (which for a manual-by-IP peer is the address). Used by every peer list via + /// . + private string ResolvePeerDisplayName(PeerAnnouncement peer) + => peerFriendlyNames.TryGetValue(PeerIdentityKey(peer), out var custom) && !string.IsNullOrWhiteSpace(custom) + ? custom + : peer.Name; + + private PeerListItem? SelectedConnectedPeer() => SafeSelectedItem(connectedPeersList) as PeerListItem; + + /// Refreshes the read-only details box for whichever peer is highlighted in the connected + /// list. Runs on selection change and each status tick (so "connected for" and ping stay live). + private void UpdatePeerDetails() + { + var item = SelectedConnectedPeer(); + renamePeerButton.Enabled = item is not null; + var text = item is null ? "Select a connected peer to see its details." : BuildPeerDetailsText(item); + if (peerDetailsBox.Text != text) peerDetailsBox.Text = text; + } + + private string BuildPeerDetailsText(PeerListItem item) + { + var peer = item.Peer; + var lines = new List(); + var display = ResolvePeerDisplayName(peer); + var machine = string.IsNullOrWhiteSpace(peer.Name) || peer.Name == peer.Address.ToString() ? null : peer.Name; + + if (!string.Equals(display, machine, StringComparison.Ordinal) && !string.Equals(display, peer.Address.ToString(), StringComparison.Ordinal)) + lines.Add($"Name: {display}"); + lines.Add($"Machine name: {machine ?? "(unknown — added by address)"}"); + lines.Add($"Address: {peer.Address}"); + + if (peerConnectedSinceUtc.TryGetValue(peer.InstanceId, out var since)) + lines.Add($"Connected for: {DescribeDuration(DateTime.UtcNow - since)}"); + + if (item.Status.Connected) + lines.Add(item.Status.RttMs is { } rtt ? $"Link: healthy, ping {rtt} ms" : "Link: healthy"); + else + lines.Add("Link: not connected"); + + lines.Add("Sending: " + DescribeSending(peer)); + lines.Add("Receiving your audio: " + (item.Status.Sending ? "yes" : "no")); + + return string.Join(Environment.NewLine, lines); + } + + /// "2 devices on ASIO at 48 kHz, Opus" — built from the live receive streams (each stream is + /// one capture device; its lane tells us WASAPI vs ASIO). Falls back gracefully when we're not + /// receiving that peer. + private string DescribeSending(PeerAnnouncement peer) + { + var formats = receiver.ActiveFormatsFromAddress(peer.Address); + if (formats.Count == 0) + return peer.CanSend ? "yes — turn on Receive audio to see the details" : "no"; + + string deviceWord = formats.Count == 1 ? "1 device" : $"{formats.Count} devices"; + + var apis = formats.Select(f => f.Lane == RenderRoute.AsioLane ? "ASIO" : "WASAPI").Distinct().OrderBy(a => a).ToList(); + string apiPart = apis.Count == 1 ? $" on {apis[0]}" : $" on {string.Join(" and ", apis)}"; + + var rates = formats.Select(f => f.SampleRate).Distinct().ToList(); + string ratePart = rates.Count == 1 ? $" at {rates[0]} Hz" : ""; + + var codecs = formats.Select(f => ((AudioTransportCodec)f.Codec) == AudioTransportCodec.Opus ? "Opus" : "PCM").Distinct().ToList(); + string codecPart = codecs.Count == 1 ? $", {codecs[0]}" : ""; + + return $"{deviceWord}{apiPart}{ratePart}{codecPart}"; + } + + private static string DescribeDuration(TimeSpan d) + { + if (d.TotalSeconds < 60) return $"{(int)d.TotalSeconds} seconds"; + if (d.TotalMinutes < 60) return $"{(int)d.TotalMinutes} minute{((int)d.TotalMinutes == 1 ? "" : "s")}"; + int hours = (int)d.TotalHours; + int mins = d.Minutes; + return mins == 0 ? $"{hours} hour{(hours == 1 ? "" : "s")}" : $"{hours} hour{(hours == 1 ? "" : "s")} {mins} minute{(mins == 1 ? "" : "s")}"; + } + + private void OnRenamePeer() + { + var item = SelectedConnectedPeer(); + if (item is null) return; + var peer = item.Peer; + var key = PeerIdentityKey(peer); + var machineForDisplay = string.IsNullOrWhiteSpace(peer.Name) || peer.Name == peer.Address.ToString() + ? peer.Address.ToString() + : peer.Name; + peerFriendlyNames.TryGetValue(key, out var current); + + using var dlg = new RenamePeerDialog(machineForDisplay, current); + if (dlg.ShowDialog(this) != DialogResult.OK) return; + ApplyFriendlyName(key, dlg.FriendlyName); + } + + /// Store (or clear) a peer's friendly name in the machine-wide book, persist it, and + /// refresh every place a peer name shows. + private void ApplyFriendlyName(string identityKey, string? name) + { + if (string.IsNullOrWhiteSpace(name)) peerFriendlyNames.Remove(identityKey); + else peerFriendlyNames[identityKey] = name.Trim(); + + try + { + var cfg = AppConfig.Load(); + cfg.PeerFriendlyNames = new Dictionary(peerFriendlyNames, StringComparer.OrdinalIgnoreCase); + cfg.Save(); + } + catch (Exception ex) { logFile.Event($"peer rename: failed to save friendly name: {ex.Message}"); } + + // Refresh everywhere: connected/discovered lists rebuild their labels, the pan/EQ list re-reads + // its peer names, and the details box updates. + lastPanEqPeerSignature = ""; + SyncAllPeerLists(); + RefreshPanEqPeerList(); + UpdatePeerDetails(); } private void SyncDiscoveredList() @@ -5872,7 +6039,7 @@ public sealed class MainForm : Form foreach (var (id, oldEndpoint) in selectedPeerEndpoints.ToList()) { if (!knownPeers.TryGetValue(id, out var peer)) continue; - selectedPeerLabels[id] = peer.Name; + selectedPeerLabels[id] = ResolvePeerDisplayName(peer); var newEndpoint = new IPEndPoint(peer.Address, peer.AudioPort); // Same address, or the one we're on is still healthy: nothing to do — and reset the @@ -5912,7 +6079,7 @@ public sealed class MainForm : Form private void SelectPeer(PeerAnnouncement peer, bool fromProfileRestore) { selectedPeerEndpoints[peer.InstanceId] = new IPEndPoint(peer.Address, peer.AudioPort); - selectedPeerLabels[peer.InstanceId] = peer.Name; + selectedPeerLabels[peer.InstanceId] = ResolvePeerDisplayName(peer); logFile.Event($"peer selected: {peer.Name} {peer.Address}:{peer.AudioPort}"); InvalidateAutoTuneHistory(); PushAllowedReceiveSenders(); diff --git a/src/RemSound.App/MainFormChoices.cs b/src/RemSound.App/MainFormChoices.cs index e09e15e..2f9bf88 100644 --- a/src/RemSound.App/MainFormChoices.cs +++ b/src/RemSound.App/MainFormChoices.cs @@ -63,6 +63,11 @@ internal sealed class PeerLineStatus internal sealed class PeerListItem { + /// Optional resolver mapping a peer to the display name to show — e.g. a user-set friendly + /// name. Set once by MainForm. When null (or it returns the machine name) behaviour is unchanged, + /// so this covers the connected AND discovered lists in one place. + public static Func? DisplayNameProvider; + public PeerAnnouncement Peer { get; } public PeerLineStatus Status { get; } = new(); @@ -79,7 +84,8 @@ internal sealed class PeerListItem // Base label: "hostname (ip)" for discovered peers, just "ip" for manual-by-IP entries // (where hostname equals the IP address). Avoids "192.168.1.95 (192.168.1.95)" duplication. var addr = Peer.Address.ToString(); - var basePart = Peer.Name == addr ? addr : $"{Peer.Name} ({addr})"; + var name = DisplayNameProvider?.Invoke(Peer) ?? Peer.Name; + var basePart = name == addr ? addr : $"{name} ({addr})"; if (!Status.Connected) { diff --git a/src/RemSound.App/RenamePeerDialog.cs b/src/RemSound.App/RenamePeerDialog.cs new file mode 100644 index 0000000..e11b048 --- /dev/null +++ b/src/RemSound.App/RenamePeerDialog.cs @@ -0,0 +1,84 @@ +namespace RemSound.App; + +/// Modal dialog for giving a peer a friendly name. Shows the machine name for reference, a +/// text box for the custom name, and a "Clear custom name" button that wipes it back to the machine +/// name. OK (or Clear) returns DialogResult.OK; the caller reads — null +/// meaning "no custom name" (cleared or left blank). Cancel/Escape makes no change. +internal sealed class RenamePeerDialog : Form +{ + private readonly TextBox nameBox = new() + { + Width = 260, + TabIndex = 0, + AccessibleName = "Friendly name (Alt+N)", + }; + + /// The chosen name, valid only when ShowDialog returned OK. Null = no custom name. + public string? FriendlyName { get; private set; } + + public RenamePeerDialog(string machineName, string? currentCustomName) + { + Text = "Rename peer"; + StartPosition = FormStartPosition.CenterParent; + FormBorderStyle = FormBorderStyle.FixedDialog; + MinimizeBox = false; + MaximizeBox = false; + ShowInTaskbar = false; + ClientSize = new Size(380, 170); + + nameBox.Text = currentCustomName ?? ""; + + var grid = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(12), + ColumnCount = 2, + RowCount = 3, + }; + grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + grid.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + + var machineLabel = new Label + { + Text = $"Machine name: {machineName}", + AutoSize = true, + Anchor = AnchorStyles.Left, + Margin = new Padding(0, 0, 0, 8), + }; + grid.Controls.Add(machineLabel, 0, 0); + grid.SetColumnSpan(machineLabel, 2); + + // Plain label with '&' wires the mnemonic to the next control in tab order (the text box). + var nameLabel = new Label { Text = "Friendly &name (Alt+N)", AutoSize = true, Anchor = AnchorStyles.Left, Margin = new Padding(0, 4, 8, 0) }; + grid.Controls.Add(nameLabel, 0, 1); + grid.Controls.Add(nameBox, 1, 1); + + var clearButton = new Button { Text = "&Clear custom name (Alt+C)", AutoSize = true, AccessibleName = "Clear custom name", TabIndex = 1 }; + var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK, TabIndex = 2 }; + var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel, TabIndex = 3 }; + + clearButton.Click += (_, _) => + { + FriendlyName = null; + DialogResult = DialogResult.OK; + Close(); + }; + okButton.Click += (_, _) => + { + var t = nameBox.Text.Trim(); + FriendlyName = t.Length == 0 ? null : t; // blank box = clear, same as the Clear button + }; + + var buttonRow = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, AutoSize = true, TabIndex = 2 }; + buttonRow.Controls.Add(clearButton); + buttonRow.Controls.Add(okButton); + buttonRow.Controls.Add(cancelButton); + grid.Controls.Add(buttonRow, 1, 2); + + Controls.Add(grid); + AcceptButton = okButton; + CancelButton = cancelButton; + + Shown += (_, _) => { nameBox.Focus(); nameBox.SelectAll(); }; + } +} diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs index bcbfd4a..9e6d190 100644 --- a/src/RemSound.Core/AppConfig.cs +++ b/src/RemSound.Core/AppConfig.cs @@ -75,6 +75,11 @@ public sealed class AppConfig /// . public bool ShowPanEqTab { get; set; } = true; + /// Custom friendly names the user has given to peers, keyed by the peer's stable identity — + /// its machine name when known, otherwise its address. Machine-wide (a name applies in every profile) + /// and shown wherever that peer appears. Empty by default. See the Connectivity tab's Rename peer. + public Dictionary PeerFriendlyNames { get; set; } = new(); + /// 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 /// per-) because it fires before any profile — and its per-profile diff --git a/src/RemSound.Receiver/AudioReceiver.cs b/src/RemSound.Receiver/AudioReceiver.cs index f428d09..0f0fba5 100644 --- a/src/RemSound.Receiver/AudioReceiver.cs +++ b/src/RemSound.Receiver/AudioReceiver.cs @@ -864,6 +864,32 @@ public sealed class AudioReceiver : IDisposable return null; } + /// + /// The format of every currently-active (non-idle) receive session from the given peer IP — one + /// entry per stream, i.e. per capture device the peer is sending. Each carries its sample rate, + /// codec and lane (WASAPI vs ASIO), so the UI can say "sending N devices on ASIO at 48 kHz, Opus" + /// with no protocol change. Empty when nothing is arriving from that peer. + /// + public IReadOnlyList ActiveFormatsFromAddress(IPAddress address) + { + var now = DateTime.UtcNow; + var fresh = new List(); + foreach (var sp in playoutEngine.ActiveSessions) + { + if (!sp.Endpoint.Address.Equals(address)) continue; + if (now - sp.LastWriteUtc > SessionIdleTimeout) continue; + fresh.Add(sp); + } + var formats = new List(); + lock (sessionsLock) + { + foreach (var sp in fresh) + if (sessions.TryGetValue((sp.Endpoint, sp.StreamId), out var session) && session.Format is not null) + formats.Add(session.Format); + } + return formats; + } + /// /// The wire codec of the freshest currently-active receive session across all peers, or /// null when nothing is being received. Surfaced in the SNAP log's Codec column so a
KeyAction
Alt+CFocus the Connected peers list
Alt+EFocus the Peer details box (for the highlighted connected peer)
Alt+MRename the highlighted connected peer
Alt+DFocus the Discovered peers list
Alt+RFocus the Remembered peers list
Alt+AAdd peer by IP