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;
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-remote-user gain, mute, and noise reduction settings.
|
/// Per-remote-user, per-stream gain/mute/noise-reduction settings (docs/voice.md §1, §10).
|
||||||
/// Changes are applied in real-time as the user adjusts controls — no OK/Cancel round-trip
|
/// One row is rendered for each stream the remote user is currently publishing. Each row
|
||||||
/// for gain and mute (the close button just dismisses). The settings live in the core and
|
/// controls only that stream — so a listener can turn down one user's desktop audio while
|
||||||
/// are not persisted across sessions.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class PerUserTuningDialog : Form
|
public sealed class PerUserTuningDialog : Form
|
||||||
{
|
{
|
||||||
private readonly VoiceCatClient _client;
|
private readonly VoiceCatClient _client;
|
||||||
private readonly uint _userId;
|
private readonly uint _userId;
|
||||||
|
|
||||||
private readonly TrackBar _trkGain;
|
private readonly List<StreamRow> _rows = new();
|
||||||
private readonly Label _lblGainValue;
|
|
||||||
private readonly CheckBox _chkMute;
|
|
||||||
private readonly CheckBox _chkNr;
|
|
||||||
|
|
||||||
public PerUserTuningDialog(VoiceCatClient client, uint userId, string nickname)
|
public PerUserTuningDialog(VoiceCatClient client, uint userId, string nickname)
|
||||||
{
|
{
|
||||||
_client = client;
|
_client = client;
|
||||||
_userId = userId;
|
_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
|
var lblTitle = new Label
|
||||||
{
|
{
|
||||||
Text = $"Settings for {nickname}",
|
Text = $"Settings for {nickname}",
|
||||||
@@ -33,93 +47,169 @@ public sealed class PerUserTuningDialog : Form
|
|||||||
TabIndex = 0,
|
TabIndex = 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
var lblGainLabel = new Label
|
var streams = _client.ListUserStreams(_userId);
|
||||||
{
|
|
||||||
Text = "&Gain:",
|
|
||||||
AutoSize = true,
|
|
||||||
Location = new Point(12, 46),
|
|
||||||
TabIndex = 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
_trkGain = new TrackBar
|
const int rowH = 70; // label + trackbar + mute/nr
|
||||||
{
|
const int rowGap = 8;
|
||||||
AccessibleName = "Gain",
|
int y = 44;
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
_lblGainValue = new Label
|
if (streams.Count == 0)
|
||||||
{
|
{
|
||||||
Text = "100% (1.0×)",
|
var lblEmpty = new Label
|
||||||
AutoSize = true,
|
{
|
||||||
Location = new Point(280, 46),
|
Text = "No active streams.",
|
||||||
TabIndex = 3,
|
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
|
Controls.Add(lblTitle);
|
||||||
{
|
|
||||||
Text = "&Mute this user",
|
|
||||||
AutoSize = true,
|
|
||||||
Location = new Point(12, 92),
|
|
||||||
TabIndex = 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
_chkNr = new CheckBox
|
int tabIndex = 1;
|
||||||
|
foreach (var s in streams)
|
||||||
{
|
{
|
||||||
Text = "&Noise reduction (planned — currently passthrough)",
|
var row = CreateRow(s, y, ref tabIndex);
|
||||||
AutoSize = true,
|
_rows.Add(row);
|
||||||
Location = new Point(12, 118),
|
Controls.AddRange(row.Controls);
|
||||||
TabIndex = 5,
|
y += rowH + rowGap;
|
||||||
};
|
}
|
||||||
|
|
||||||
var btnClose = new Button
|
var btnClose = new Button
|
||||||
{
|
{
|
||||||
Text = "&Close",
|
Text = "&Close",
|
||||||
DialogResult = DialogResult.OK,
|
DialogResult = DialogResult.OK,
|
||||||
Location = new Point(296, 152),
|
Location = new Point(336, y + 4),
|
||||||
Size = new Size(75, 27),
|
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 ───────────────────────────────────────────────────────
|
var trkGain = new TrackBar
|
||||||
_trkGain.Scroll += (_, _) =>
|
{
|
||||||
|
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;
|
float gain = _trkGain.Value / 100f;
|
||||||
_lblGainValue.Text = $"{_trkGain.Value}% ({gain:F1}×)";
|
client.SetRemoteStream(userId, _streamId, gain, _chkMute.Checked, _chkNr.Checked);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,4 +293,103 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
|
|||||||
|
|
||||||
client.Disconnect();
|
client.Disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-stream receive-side controls (gain/mute/NR) round-trip through P/Invoke: two
|
||||||
|
/// clients in a channel, one publishes a MIC stream, the other SetRemoteStream's it then
|
||||||
|
/// GetRemoteStream's it back. Catches P/Invoke-specific marshaling bugs in
|
||||||
|
/// VcRemoteStreamStateNative (field order, bool-from-int, float precision) that the C++
|
||||||
|
/// ctest (test_m3_multistream) cannot. See docs/voice.md §1, §10.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void PerStream_RecvControls_RoundTrip_Through_PInvoke()
|
||||||
|
{
|
||||||
|
var eventsA = new List<VoiceCatEvent>();
|
||||||
|
var eventsB = new List<VoiceCatEvent>();
|
||||||
|
using var a = new VoiceCatClient("vc-csharp-mix-a", "0.1", VcLogLevel.Off,
|
||||||
|
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_a.txt"));
|
||||||
|
using var b = new VoiceCatClient("vc-csharp-mix-b", "0.1", VcLogLevel.Off,
|
||||||
|
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_b.txt"));
|
||||||
|
a.EventReceived += eventsA.Add;
|
||||||
|
b.EventReceived += eventsB.Add;
|
||||||
|
|
||||||
|
// Connect + auth A first, then B — staggering avoids concurrent TLS handshakes against
|
||||||
|
// the same server (mirrors the C++ test_m3_multistream harness, which connects A to
|
||||||
|
// completion before starting B).
|
||||||
|
Assert.Equal(VcResult.Ok, a.Connect("127.0.0.1", _port));
|
||||||
|
Assert.Equal(VcResult.Ok, a.AuthenticateGuest("CSharpMixA"));
|
||||||
|
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
|
||||||
|
Assert.Equal(VcResult.Ok, a.ConfirmServerIdentity(accept: true));
|
||||||
|
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.AuthResult), 5000));
|
||||||
|
Assert.Equal(VcResult.Ok, eventsA.First(e => e.Type == VcEventType.AuthResult).Result);
|
||||||
|
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ChannelList), 3000));
|
||||||
|
|
||||||
|
Assert.Equal(VcResult.Ok, b.Connect("127.0.0.1", _port));
|
||||||
|
Assert.Equal(VcResult.Ok, b.AuthenticateGuest("CSharpMixB"));
|
||||||
|
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
|
||||||
|
Assert.Equal(VcResult.Ok, b.ConfirmServerIdentity(accept: true));
|
||||||
|
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.AuthResult), 5000));
|
||||||
|
Assert.Equal(VcResult.Ok, eventsB.First(e => e.Type == VcEventType.AuthResult).Result);
|
||||||
|
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ChannelList), 3000));
|
||||||
|
|
||||||
|
// Both join Lobby (channel 1) so voice relays between them.
|
||||||
|
Assert.Equal(VcResult.Ok, a.JoinChannel(1, null));
|
||||||
|
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.JoinResult), 5000),
|
||||||
|
"A did not receive VC_EVENT_JOIN_RESULT");
|
||||||
|
Assert.Equal(VcResult.Ok, b.JoinChannel(1, null));
|
||||||
|
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.JoinResult), 5000),
|
||||||
|
"B did not receive VC_EVENT_JOIN_RESULT");
|
||||||
|
|
||||||
|
// UDP binding handshake is async; give it a moment (mirrors ScreenAudio test).
|
||||||
|
Thread.Sleep(500);
|
||||||
|
|
||||||
|
// A publishes a MIC stream.
|
||||||
|
var (startResult, streamId) = a.StartStream(VcStreamKind.Mic, "mix-test-mic");
|
||||||
|
Assert.Equal(VcResult.Ok, startResult);
|
||||||
|
Assert.True(streamId != 0);
|
||||||
|
|
||||||
|
// B sees A's stream and can enumerate it.
|
||||||
|
uint aUid = 0;
|
||||||
|
Assert.True(PumpUntil(b, () =>
|
||||||
|
{
|
||||||
|
return eventsB.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId);
|
||||||
|
}, 5000), "B did not see A's STREAM_STARTED");
|
||||||
|
// Resolve A's user id from B's user list.
|
||||||
|
Assert.True(PumpUntil(b, () =>
|
||||||
|
{
|
||||||
|
aUid = b.ListUsers().FirstOrDefault(u => u.Nickname == "CSharpMixA")?.Id ?? 0;
|
||||||
|
return aUid != 0;
|
||||||
|
}, 3000), "could not resolve A's user id on B");
|
||||||
|
Assert.True(aUid != 0);
|
||||||
|
|
||||||
|
Assert.True(PumpUntil(b, () => b.ListUserStreams(aUid).Any(s => s.StreamId == streamId), 3000),
|
||||||
|
"B could not enumerate A's stream");
|
||||||
|
var bStreams = b.ListUserStreams(aUid);
|
||||||
|
Assert.Contains(bStreams, s => s.StreamId == streamId && s.Kind == VcStreamKind.Mic);
|
||||||
|
|
||||||
|
// Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off).
|
||||||
|
var (r0, st0) = b.GetRemoteStream(aUid, streamId);
|
||||||
|
Assert.Equal(VcResult.Ok, r0);
|
||||||
|
Assert.NotNull(st0);
|
||||||
|
Assert.Equal(1.0f, st0!.Gain);
|
||||||
|
Assert.False(st0.Muted);
|
||||||
|
Assert.False(st0.NoiseReduction);
|
||||||
|
|
||||||
|
// B turns A down to 0.4×, mutes, and enables NR — then reads it back.
|
||||||
|
Assert.Equal(VcResult.Ok, b.SetRemoteStream(aUid, streamId, 0.4f, muted: true, noiseReduction: true));
|
||||||
|
var (r1, st1) = b.GetRemoteStream(aUid, streamId);
|
||||||
|
Assert.Equal(VcResult.Ok, r1);
|
||||||
|
Assert.NotNull(st1);
|
||||||
|
Assert.Equal(0.4f, st1!.Gain);
|
||||||
|
Assert.True(st1.Muted);
|
||||||
|
Assert.True(st1.NoiseReduction);
|
||||||
|
|
||||||
|
// Unknown stream id on a known user -> INVALID_ARG.
|
||||||
|
var (rBad, stBad) = b.GetRemoteStream(aUid, 0xDEADBEEF);
|
||||||
|
Assert.Equal(VcResult.InvalidArg, rBad);
|
||||||
|
Assert.Null(stBad);
|
||||||
|
|
||||||
|
a.Disconnect();
|
||||||
|
b.Disconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ internal static class Marshaling
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static RemoteStreamState ToManaged(in VcRemoteStreamStateNative native) => new(
|
||||||
|
native.Gain,
|
||||||
|
native.Muted != 0,
|
||||||
|
native.NoiseReduction != 0);
|
||||||
|
|
||||||
public static AudioConfigInfo ToManaged(in VcAudioConfigNative native) => new(
|
public static AudioConfigInfo ToManaged(in VcAudioConfigNative native) => new(
|
||||||
native.Codec,
|
native.Codec,
|
||||||
native.Mode != 0,
|
native.Mode != 0,
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ public sealed record StreamSummary(
|
|||||||
VcStreamKind Kind,
|
VcStreamKind Kind,
|
||||||
string Label);
|
string Label);
|
||||||
|
|
||||||
|
public sealed record RemoteStreamState(
|
||||||
|
float Gain,
|
||||||
|
bool Muted,
|
||||||
|
bool NoiseReduction);
|
||||||
|
|
||||||
public sealed record DeviceInfo(
|
public sealed record DeviceInfo(
|
||||||
string Id,
|
string Id,
|
||||||
string Name,
|
string Name,
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ internal static partial class NativeMethods
|
|||||||
internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId,
|
internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId,
|
||||||
float gain, int muted, int noiseReduction);
|
float gain, int muted, int noiseReduction);
|
||||||
|
|
||||||
|
[LibraryImport(LibName)]
|
||||||
|
internal static partial VcResult vc_get_remote_stream(nint c, uint userId, uint streamId,
|
||||||
|
out VcRemoteStreamStateNative outState);
|
||||||
|
|
||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_get_stream_audio_config(nint c, uint userId,
|
internal static partial VcResult vc_get_stream_audio_config(nint c, uint userId,
|
||||||
uint streamId, out VcAudioConfigNative outCfg);
|
uint streamId, out VcAudioConfigNative outCfg);
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ internal struct VcStreamSummaryListNative
|
|||||||
public nuint Count;
|
public nuint Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct VcRemoteStreamStateNative
|
||||||
|
{
|
||||||
|
public float Gain;
|
||||||
|
public int Muted;
|
||||||
|
public int NoiseReduction;
|
||||||
|
}
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
internal struct VcAccountNative
|
internal struct VcAccountNative
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -208,6 +208,13 @@ public sealed class VoiceCatClient : IDisposable
|
|||||||
NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain,
|
NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain,
|
||||||
muted ? 1 : 0, noiseReduction ? 1 : 0);
|
muted ? 1 : 0, noiseReduction ? 1 : 0);
|
||||||
|
|
||||||
|
public (VcResult Result, RemoteStreamState? State) GetRemoteStream(uint userId, uint streamId)
|
||||||
|
{
|
||||||
|
var r = NativeMethods.vc_get_remote_stream(_handle.DangerousGetHandle(), userId,
|
||||||
|
streamId, out var native);
|
||||||
|
return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null);
|
||||||
|
}
|
||||||
|
|
||||||
public (VcResult Result, AudioConfigInfo? Config) GetStreamAudioConfig(uint userId, uint streamId)
|
public (VcResult Result, AudioConfigInfo? Config) GetStreamAudioConfig(uint userId, uint streamId)
|
||||||
{
|
{
|
||||||
var r = NativeMethods.vc_get_stream_audio_config(_handle.DangerousGetHandle(), userId,
|
var r = NativeMethods.vc_get_stream_audio_config(_handle.DangerousGetHandle(), userId,
|
||||||
|
|||||||
@@ -319,6 +319,17 @@ typedef struct vc_stream_summary_list {
|
|||||||
size_t count;
|
size_t count;
|
||||||
} vc_stream_summary_list;
|
} vc_stream_summary_list;
|
||||||
|
|
||||||
|
/* Receive-side state the local listener has chosen for a specific remote stream — the
|
||||||
|
* counterpart to vc_set_remote_stream, so a UI can reopen its per-mix controls at the
|
||||||
|
* listener's actual current settings. All LOCAL (no protocol traffic) — docs/voice.md §10.
|
||||||
|
* If (user_id, stream_id) is known but the listener has never called vc_set_remote_stream on
|
||||||
|
* it, the defaults are gain=1.0, muted=0, noise_reduction=0 (matching a fresh RemoteStream). */
|
||||||
|
typedef struct vc_remote_stream_state {
|
||||||
|
float gain; /* 0.0–… ; default 1.0 */
|
||||||
|
int muted; /* bool */
|
||||||
|
int noise_reduction; /* bool */
|
||||||
|
} vc_remote_stream_state;
|
||||||
|
|
||||||
/* Opaque client handle. */
|
/* Opaque client handle. */
|
||||||
typedef struct vc_client vc_client;
|
typedef struct vc_client vc_client;
|
||||||
|
|
||||||
@@ -365,7 +376,13 @@ VC_API vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened);
|
|||||||
/* Receive-side, per remote stream, all LOCAL (no protocol traffic) — docs/voice.md §10:
|
/* Receive-side, per remote stream, all LOCAL (no protocol traffic) — docs/voice.md §10:
|
||||||
* gain (0..) , mute, and listener-chosen noise reduction on a specific user's stream. */
|
* gain (0..) , mute, and listener-chosen noise reduction on a specific user's stream. */
|
||||||
VC_API vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id,
|
VC_API vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id,
|
||||||
float gain, int muted, int noise_reduction);
|
float gain, int muted, int noise_reduction);
|
||||||
|
|
||||||
|
/* Reads back the receive-side state last set on (user_id, stream_id) via
|
||||||
|
* vc_set_remote_stream (or the defaults if never set). VC_ERR_INVALID_ARG if the user/stream
|
||||||
|
* isn't known. */
|
||||||
|
VC_API vc_result vc_get_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id,
|
||||||
|
vc_remote_stream_state* out);
|
||||||
|
|
||||||
/* Effective Opus config in use for (user_id, stream_id) — your own stream or a peer's.
|
/* Effective Opus config in use for (user_id, stream_id) — your own stream or a peer's.
|
||||||
* VC_ERR_INVALID_ARG if the user/stream isn't known. */
|
* VC_ERR_INVALID_ARG if the user/stream isn't known. */
|
||||||
|
|||||||
@@ -334,6 +334,17 @@ void AudioEngine::set_stream_noise_reduction(uint32_t ssrc, bool enable) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool AudioEngine::get_stream_state(uint32_t ssrc, float& gain, bool& mute, bool& noise_reduction) {
|
||||||
|
std::lock_guard lk(streams_mu_);
|
||||||
|
auto it = streams_.find(ssrc);
|
||||||
|
if (it == streams_.end()) return false;
|
||||||
|
const auto& s = it->second;
|
||||||
|
gain = s.gain;
|
||||||
|
mute = s.mute;
|
||||||
|
noise_reduction = s.noise_reduction_enabled;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void AudioEngine::remove_stream(uint32_t ssrc) {
|
void AudioEngine::remove_stream(uint32_t ssrc) {
|
||||||
std::lock_guard lk(streams_mu_);
|
std::lock_guard lk(streams_mu_);
|
||||||
streams_.erase(ssrc);
|
streams_.erase(ssrc);
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ class AudioEngine {
|
|||||||
// Listener-chosen, local-only noise reduction on a specific remote stream (docs/voice.md
|
// Listener-chosen, local-only noise reduction on a specific remote stream (docs/voice.md
|
||||||
// §10) — lazily instantiates an ApmProcessor on first enable, frees it on disable.
|
// §10) — lazily instantiates an ApmProcessor on first enable, frees it on disable.
|
||||||
void set_stream_noise_reduction(uint32_t ssrc, bool enable);
|
void set_stream_noise_reduction(uint32_t ssrc, bool enable);
|
||||||
|
// Read back a stream's current receive-side state. Returns true and fills *out if the
|
||||||
|
// stream is known (even if defaults — gain=1, mute=false, nr=false), false if it has
|
||||||
|
// never been seen (no RemoteStream entry yet).
|
||||||
|
bool get_stream_state(uint32_t ssrc, float& gain, bool& mute, bool& noise_reduction);
|
||||||
void remove_stream(uint32_t ssrc);
|
void remove_stream(uint32_t ssrc);
|
||||||
|
|
||||||
// Edge-triggered talk-state transitions since the last call (docs/voice.md §7: talk state
|
// Edge-triggered talk-state transitions since the last call (docs/voice.md §7: talk state
|
||||||
|
|||||||
@@ -1360,6 +1360,38 @@ vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, flo
|
|||||||
return VC_OK;
|
return VC_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vc_result vc_client::get_remote_stream(uint32_t user_id, uint32_t stream_id,
|
||||||
|
vc_remote_stream_state* out) {
|
||||||
|
if (out == nullptr) return VC_ERR_INVALID_ARG;
|
||||||
|
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||||
|
uint32_t ssrc = 0;
|
||||||
|
bool found = false;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||||
|
const auto* user = session_model_.find_user(user_id);
|
||||||
|
if (user) {
|
||||||
|
for (const auto& s : user->streams) {
|
||||||
|
if (s.stream_id == stream_id) { ssrc = s.ssrc; found = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) return VC_ERR_INVALID_ARG;
|
||||||
|
// The (user, stream) is known. The RemoteStream entry may not exist yet if the listener
|
||||||
|
// has neither set any control nor received audio for it — in that case report the
|
||||||
|
// defaults (docs/voice.md §10) so the UI opens at 100/unmuted/NR-off.
|
||||||
|
float gain = 1.0f; bool mute = false; bool nr = false;
|
||||||
|
if (audio_engine_.get_stream_state(ssrc, gain, mute, nr)) {
|
||||||
|
out->gain = gain;
|
||||||
|
out->muted = mute ? 1 : 0;
|
||||||
|
out->noise_reduction = nr ? 1 : 0;
|
||||||
|
} else {
|
||||||
|
out->gain = 1.0f;
|
||||||
|
out->muted = 0;
|
||||||
|
out->noise_reduction = 0;
|
||||||
|
}
|
||||||
|
return VC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
|
vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
|
||||||
vc_audio_config* out) {
|
vc_audio_config* out) {
|
||||||
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||||
@@ -1775,6 +1807,9 @@ vc_result vc_client::set_self_mute(bool, bool) { return VC_ERR_NOT_I
|
|||||||
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) {
|
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) {
|
||||||
return VC_ERR_NOT_IMPLEMENTED;
|
return VC_ERR_NOT_IMPLEMENTED;
|
||||||
}
|
}
|
||||||
|
vc_result vc_client::get_remote_stream(uint32_t, uint32_t, vc_remote_stream_state*) {
|
||||||
|
return VC_ERR_NOT_IMPLEMENTED;
|
||||||
|
}
|
||||||
vc_result vc_client::send_text(vc_text_scope, uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
vc_result vc_client::send_text(vc_text_scope, uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
vc_result vc_client::list_devices(vc_device_kind, vc_device_list* out) {
|
vc_result vc_client::list_devices(vc_device_kind, vc_device_list* out) {
|
||||||
out->items = nullptr;
|
out->items = nullptr;
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ struct vc_client {
|
|||||||
vc_result set_self_mute(bool mic_muted, bool deafened);
|
vc_result set_self_mute(bool mic_muted, bool deafened);
|
||||||
vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted,
|
vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted,
|
||||||
bool noise_reduction);
|
bool noise_reduction);
|
||||||
|
vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id,
|
||||||
|
vc_remote_stream_state* out);
|
||||||
|
|
||||||
vc_result send_text(vc_text_scope scope, uint32_t target_id, const char* utf8);
|
vc_result send_text(vc_text_scope scope, uint32_t target_id, const char* utf8);
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_i
|
|||||||
return c->set_remote_stream(user_id, stream_id, gain, muted != 0, noise_reduction != 0);
|
return c->set_remote_stream(user_id, stream_id, gain, muted != 0, noise_reduction != 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vc_result vc_get_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id,
|
||||||
|
vc_remote_stream_state* out) {
|
||||||
|
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||||
|
return c->get_remote_stream(user_id, stream_id, out);
|
||||||
|
}
|
||||||
|
|
||||||
vc_result vc_get_stream_audio_config(vc_client* c, uint32_t user_id, uint32_t stream_id,
|
vc_result vc_get_stream_audio_config(vc_client* c, uint32_t user_id, uint32_t stream_id,
|
||||||
vc_audio_config* out) {
|
vc_audio_config* out) {
|
||||||
if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG;
|
if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG;
|
||||||
|
|||||||
@@ -214,6 +214,11 @@ it is a local UI action with **no protocol message** and no effect on other list
|
|||||||
each receive stream is decoded independently before the mixer (voice.md §1), per-user receive
|
each receive stream is decoded independently before the mixer (voice.md §1), per-user receive
|
||||||
NS is a clean drop-in on that per-stream stage.
|
NS is a clean drop-in on that per-stream stage.
|
||||||
|
|
||||||
|
All three receive-side controls (gain, mute, NR) are queryable via `vc_get_remote_stream` —
|
||||||
|
the counterpart to `vc_set_remote_stream` — so a UI can reopen its per-stream mix controls at
|
||||||
|
the listener's actual current settings (defaults: gain 1.0, unmuted, NR off). Like the setter,
|
||||||
|
it carries no protocol traffic.
|
||||||
|
|
||||||
## 11. Input activation — VAD and PTT (client-configurable)
|
## 11. Input activation — VAD and PTT (client-configurable)
|
||||||
|
|
||||||
Whether the mic transmits is decided locally by the **input gate**, and the client supports
|
Whether the mic transmits is decided locally by the **input gate**, and the client supports
|
||||||
|
|||||||
@@ -289,6 +289,28 @@ int main() {
|
|||||||
}
|
}
|
||||||
{ std::lock_guard lk(evB.mu); CHECK(!evB.disconnected); }
|
{ std::lock_guard lk(evB.mu); CHECK(!evB.disconnected); }
|
||||||
|
|
||||||
|
// ── 3b. Read back what B just set (vc_get_remote_stream round-trips the recv state) ─
|
||||||
|
{
|
||||||
|
vc_remote_stream_state st{};
|
||||||
|
// mic_sid: last write above was (1.0, mute=0, nr=0)
|
||||||
|
CHECK(vc_get_remote_stream(clientB, a_uid, mic_sid, &st) == VC_OK);
|
||||||
|
CHECK(fabsf(st.gain - 1.0f) < 1e-5f);
|
||||||
|
CHECK(st.muted == 0);
|
||||||
|
CHECK(st.noise_reduction == 0);
|
||||||
|
|
||||||
|
// screen_sid: set to (0.3, mute=1, nr=1) at line ~282
|
||||||
|
CHECK(vc_get_remote_stream(clientB, a_uid, screen_sid, &st) == VC_OK);
|
||||||
|
CHECK(fabsf(st.gain - 0.3f) < 1e-5f);
|
||||||
|
CHECK(st.muted == 1);
|
||||||
|
CHECK(st.noise_reduction == 1);
|
||||||
|
|
||||||
|
// Unknown stream_id on a known user -> INVALID_ARG.
|
||||||
|
CHECK(vc_get_remote_stream(clientB, a_uid, 0xDEADBEEF, &st) == VC_ERR_INVALID_ARG);
|
||||||
|
// Null out -> INVALID_ARG.
|
||||||
|
CHECK(vc_get_remote_stream(clientB, a_uid, mic_sid, nullptr) == VC_ERR_INVALID_ARG);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── 4. Per-channel Opus configurability ──────────────────────────────────
|
// ── 4. Per-channel Opus configurability ──────────────────────────────────
|
||||||
// A moves to "Music Room" (channel 2: stereo/128kbps/OPUS_AUDIO/no DTX) and announces a
|
// A moves to "Music Room" (channel 2: stereo/128kbps/OPUS_AUDIO/no DTX) and announces a
|
||||||
// fresh MIC stream there; B stays in "Lobby" (channel 1: mono/24kbps/OPUS_VOIP/DTX) with
|
// fresh MIC stream there; B stays in "Lobby" (channel 1: mono/24kbps/OPUS_VOIP/DTX) with
|
||||||
|
|||||||
Reference in New Issue
Block a user