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:
@@ -293,4 +293,103 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user