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:
2026-06-18 02:06:44 +02:00
parent 1dfe3c95ed
commit d397731db9
15 changed files with 402 additions and 82 deletions

View File

@@ -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();
}
}