feat(audio): per-stream mix controls in Windows client + vc_get_remote_stream getter
PerUserTuningDialog previously broadcast one gain/mute/NR set to *all* of a user's streams, even though the core mixer (AudioEngine::RemoteStream) and the C ABI (vc_set_remote_stream) were already per-stream. The UI had no per-mix controls anywhere. Reworks the dialog to enumerate ListUserStreams on open and render one row per stream (kind + label + Gain + Mute + NR), each wiring only to its own stream_id. Adds a read-back ABI counterpart, vc_get_remote_stream, so the dialog opens at the listener's actual current per-stream settings (defaults 1.0/unmuted/NR-off) rather than always 100%. Additive ABI change only; no existing symbols touched. Tests: test_m3_multistream extended with getter round-trip assertions; new C# smoke test exercises the full P/Invoke marshaling path with two clients. Docs: voice.md §10 notes the getter. NR checkbox keeps its honest 'passthrough' label (NS DSP still unbuilt per §8).
This commit is contained in:
@@ -3,27 +3,41 @@ using VoiceCat.Interop;
|
||||
namespace VoiceCat.App.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// Per-remote-user gain, mute, and noise reduction settings.
|
||||
/// Changes are applied in real-time as the user adjusts controls — no OK/Cancel round-trip
|
||||
/// for gain and mute (the close button just dismisses). The settings live in the core and
|
||||
/// are not persisted across sessions.
|
||||
/// Per-remote-user, per-stream gain/mute/noise-reduction settings (docs/voice.md §1, §10).
|
||||
/// One row is rendered for each stream the remote user is currently publishing. Each row
|
||||
/// controls only that stream — so a listener can turn down one user's desktop audio while
|
||||
/// keeping their mic, and independently noise-reduce a third user. All of these are
|
||||
/// listener-local: no protocol traffic, no effect on other listeners.
|
||||
///
|
||||
/// Changes apply in real time as the user adjusts controls (no OK/Cancel round-trip); the
|
||||
/// Close button just dismisses. State lives in the core and is not persisted across sessions.
|
||||
/// The dialog is a modal snapshot of the streams active when it was opened — close and
|
||||
/// reopen to see streams started/stopped in the meantime.
|
||||
/// </summary>
|
||||
public sealed class PerUserTuningDialog : Form
|
||||
{
|
||||
private readonly VoiceCatClient _client;
|
||||
private readonly uint _userId;
|
||||
|
||||
private readonly TrackBar _trkGain;
|
||||
private readonly Label _lblGainValue;
|
||||
private readonly CheckBox _chkMute;
|
||||
private readonly CheckBox _chkNr;
|
||||
private readonly List<StreamRow> _rows = new();
|
||||
|
||||
public PerUserTuningDialog(VoiceCatClient client, uint userId, string nickname)
|
||||
{
|
||||
_client = client;
|
||||
_userId = userId;
|
||||
|
||||
// ── Controls ──────────────────────────────────────────────────────────
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
Text = $"User settings — {nickname}";
|
||||
|
||||
BuildControls(nickname);
|
||||
}
|
||||
|
||||
private void BuildControls(string nickname)
|
||||
{
|
||||
var lblTitle = new Label
|
||||
{
|
||||
Text = $"Settings for {nickname}",
|
||||
@@ -33,93 +47,169 @@ public sealed class PerUserTuningDialog : Form
|
||||
TabIndex = 0,
|
||||
};
|
||||
|
||||
var lblGainLabel = new Label
|
||||
{
|
||||
Text = "&Gain:",
|
||||
AutoSize = true,
|
||||
Location = new Point(12, 46),
|
||||
TabIndex = 1,
|
||||
};
|
||||
var streams = _client.ListUserStreams(_userId);
|
||||
|
||||
_trkGain = new TrackBar
|
||||
{
|
||||
AccessibleName = "Gain",
|
||||
AccessibleDescription = "Volume level for this user. 100 is normal (1.0×), 200 is double.",
|
||||
Location = new Point(55, 38),
|
||||
Size = new Size(220, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
Value = 100,
|
||||
TickFrequency = 25,
|
||||
SmallChange = 5,
|
||||
LargeChange = 25,
|
||||
TabIndex = 2,
|
||||
};
|
||||
const int rowH = 70; // label + trackbar + mute/nr
|
||||
const int rowGap = 8;
|
||||
int y = 44;
|
||||
|
||||
_lblGainValue = new Label
|
||||
if (streams.Count == 0)
|
||||
{
|
||||
Text = "100% (1.0×)",
|
||||
AutoSize = true,
|
||||
Location = new Point(280, 46),
|
||||
TabIndex = 3,
|
||||
};
|
||||
var lblEmpty = new Label
|
||||
{
|
||||
Text = "No active streams.",
|
||||
AutoSize = true,
|
||||
Location = new Point(12, y),
|
||||
};
|
||||
Controls.Add(lblTitle);
|
||||
Controls.Add(lblEmpty);
|
||||
ClientSize = new Size(420, 44 + lblEmpty.PreferredHeight + 16);
|
||||
return;
|
||||
}
|
||||
|
||||
_chkMute = new CheckBox
|
||||
{
|
||||
Text = "&Mute this user",
|
||||
AutoSize = true,
|
||||
Location = new Point(12, 92),
|
||||
TabIndex = 4,
|
||||
};
|
||||
Controls.Add(lblTitle);
|
||||
|
||||
_chkNr = new CheckBox
|
||||
int tabIndex = 1;
|
||||
foreach (var s in streams)
|
||||
{
|
||||
Text = "&Noise reduction (planned — currently passthrough)",
|
||||
AutoSize = true,
|
||||
Location = new Point(12, 118),
|
||||
TabIndex = 5,
|
||||
};
|
||||
var row = CreateRow(s, y, ref tabIndex);
|
||||
_rows.Add(row);
|
||||
Controls.AddRange(row.Controls);
|
||||
y += rowH + rowGap;
|
||||
}
|
||||
|
||||
var btnClose = new Button
|
||||
{
|
||||
Text = "&Close",
|
||||
DialogResult = DialogResult.OK,
|
||||
Location = new Point(296, 152),
|
||||
Location = new Point(336, y + 4),
|
||||
Size = new Size(75, 27),
|
||||
TabIndex = 6,
|
||||
TabIndex = tabIndex,
|
||||
};
|
||||
AcceptButton = btnClose;
|
||||
|
||||
Controls.Add(btnClose);
|
||||
|
||||
ClientSize = new Size(420, y + 40);
|
||||
}
|
||||
|
||||
private StreamRow CreateRow(StreamSummary s, int y, ref int tabIndex)
|
||||
{
|
||||
// Read back the listener's current state for this stream (defaults if never set).
|
||||
float gain0 = 1.0f; bool mute0 = false; bool nr0 = false;
|
||||
var (r, state) = _client.GetRemoteStream(_userId, s.StreamId);
|
||||
if (r == VcResult.Ok && state is not null)
|
||||
{
|
||||
gain0 = state.Gain;
|
||||
mute0 = state.Muted;
|
||||
nr0 = state.NoiseReduction;
|
||||
}
|
||||
|
||||
var lblName = new Label
|
||||
{
|
||||
Text = StreamLabel(s),
|
||||
AutoSize = true,
|
||||
Font = new Font(Font, FontStyle.Bold),
|
||||
Location = new Point(12, y),
|
||||
TabIndex = tabIndex++,
|
||||
};
|
||||
|
||||
// ── Wire events ───────────────────────────────────────────────────────
|
||||
_trkGain.Scroll += (_, _) =>
|
||||
var trkGain = new TrackBar
|
||||
{
|
||||
AccessibleName = $"Gain for {s.Label}",
|
||||
AccessibleDescription = "Volume level for this stream. 100 is normal (1.0×), 200 is double.",
|
||||
Location = new Point(12, y + 20),
|
||||
Size = new Size(260, 45),
|
||||
Minimum = 0,
|
||||
Maximum = 200,
|
||||
Value = ClampToTrack(gain0),
|
||||
TickFrequency = 25,
|
||||
SmallChange = 5,
|
||||
LargeChange = 25,
|
||||
TabIndex = tabIndex++,
|
||||
};
|
||||
|
||||
var lblGainValue = new Label
|
||||
{
|
||||
AutoSize = true,
|
||||
Location = new Point(280, y + 28),
|
||||
TabIndex = tabIndex++,
|
||||
};
|
||||
void UpdateGainLabel() =>
|
||||
lblGainValue.Text = $"{trkGain.Value}% ({trkGain.Value / 100f:F1}×)";
|
||||
UpdateGainLabel();
|
||||
|
||||
var chkMute = new CheckBox
|
||||
{
|
||||
Text = "&Mute",
|
||||
AutoSize = true,
|
||||
Location = new Point(12, y + 48),
|
||||
Checked = mute0,
|
||||
TabIndex = tabIndex++,
|
||||
};
|
||||
|
||||
var chkNr = new CheckBox
|
||||
{
|
||||
Text = "&Noise reduction (planned — currently passthrough)",
|
||||
AutoSize = true,
|
||||
Location = new Point(96, y + 48),
|
||||
Checked = nr0,
|
||||
TabIndex = tabIndex++,
|
||||
};
|
||||
|
||||
var row = new StreamRow(s.StreamId, trkGain, chkMute, chkNr,
|
||||
new Control[] { lblName, trkGain, lblGainValue, chkMute, chkNr });
|
||||
|
||||
trkGain.Scroll += (_, _) =>
|
||||
{
|
||||
UpdateGainLabel();
|
||||
row.Apply(_client, _userId);
|
||||
};
|
||||
chkMute.CheckedChanged += (_, _) => row.Apply(_client, _userId);
|
||||
chkNr.CheckedChanged += (_, _) => row.Apply(_client, _userId);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private static string StreamLabel(StreamSummary s)
|
||||
{
|
||||
var kind = s.Kind switch
|
||||
{
|
||||
VcStreamKind.Mic => "Mic",
|
||||
VcStreamKind.ScreenAudio => "Screen audio",
|
||||
VcStreamKind.AuxDevice => "Aux device",
|
||||
_ => "Stream",
|
||||
};
|
||||
return string.IsNullOrWhiteSpace(s.Label) ? kind : $"{kind} — {s.Label}";
|
||||
}
|
||||
|
||||
private static int ClampToTrack(float gain)
|
||||
{
|
||||
int v = (int)Math.Round(gain * 100f);
|
||||
return Math.Max(0, Math.Min(200, v));
|
||||
}
|
||||
|
||||
private sealed class StreamRow
|
||||
{
|
||||
private readonly uint _streamId;
|
||||
private readonly TrackBar _trkGain;
|
||||
private readonly CheckBox _chkMute;
|
||||
private readonly CheckBox _chkNr;
|
||||
public Control[] Controls { get; }
|
||||
|
||||
public StreamRow(uint streamId, TrackBar trkGain, CheckBox chkMute, CheckBox chkNr, Control[] controls)
|
||||
{
|
||||
_streamId = streamId;
|
||||
_trkGain = trkGain;
|
||||
_chkMute = chkMute;
|
||||
_chkNr = chkNr;
|
||||
Controls = controls;
|
||||
}
|
||||
|
||||
public void Apply(VoiceCatClient client, uint userId)
|
||||
{
|
||||
float gain = _trkGain.Value / 100f;
|
||||
_lblGainValue.Text = $"{_trkGain.Value}% ({gain:F1}×)";
|
||||
ApplySettings();
|
||||
};
|
||||
_chkMute.CheckedChanged += (_, _) => ApplySettings();
|
||||
_chkNr.CheckedChanged += (_, _) => ApplySettings();
|
||||
|
||||
// ── Form ──────────────────────────────────────────────────────────────
|
||||
AcceptButton = btnClose;
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(384, 192);
|
||||
Controls.AddRange([lblTitle, lblGainLabel, _trkGain, _lblGainValue,
|
||||
_chkMute, _chkNr, btnClose]);
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
Text = $"User settings — {nickname}";
|
||||
}
|
||||
|
||||
private void ApplySettings()
|
||||
{
|
||||
float gain = _trkGain.Value / 100f;
|
||||
bool muted = _chkMute.Checked;
|
||||
bool nr = _chkNr.Checked;
|
||||
// Apply to all of this user's streams
|
||||
var streams = _client.ListUserStreams(_userId);
|
||||
foreach (var s in streams)
|
||||
_client.SetRemoteStream(_userId, s.StreamId, gain, muted, nr);
|
||||
client.SetRemoteStream(userId, _streamId, gain, _chkMute.Checked, _chkNr.Checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user