diff --git a/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs b/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs index ce9dbdd..d1b78a1 100644 --- a/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs @@ -3,27 +3,41 @@ using VoiceCat.Interop; namespace VoiceCat.App.Forms; /// -/// 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. /// 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 _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); + } } } diff --git a/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs b/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs index 4d4c0f4..b20e04c 100644 --- a/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs +++ b/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs @@ -293,4 +293,103 @@ public sealed class VoiceCatClientSmokeTests : IDisposable client.Disconnect(); } + + /// + /// 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. + /// + [Fact] + public void PerStream_RecvControls_RoundTrip_Through_PInvoke() + { + var eventsA = new List(); + var eventsB = new List(); + 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(); + } } diff --git a/clients/windows/VoiceCat.Interop/Marshaling.cs b/clients/windows/VoiceCat.Interop/Marshaling.cs index ffd80da..a48d1be 100644 --- a/clients/windows/VoiceCat.Interop/Marshaling.cs +++ b/clients/windows/VoiceCat.Interop/Marshaling.cs @@ -81,6 +81,11 @@ internal static class Marshaling 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( native.Codec, native.Mode != 0, diff --git a/clients/windows/VoiceCat.Interop/Models.cs b/clients/windows/VoiceCat.Interop/Models.cs index 26fb084..59a6749 100644 --- a/clients/windows/VoiceCat.Interop/Models.cs +++ b/clients/windows/VoiceCat.Interop/Models.cs @@ -51,6 +51,11 @@ public sealed record StreamSummary( VcStreamKind Kind, string Label); +public sealed record RemoteStreamState( + float Gain, + bool Muted, + bool NoiseReduction); + public sealed record DeviceInfo( string Id, string Name, diff --git a/clients/windows/VoiceCat.Interop/NativeMethods.cs b/clients/windows/VoiceCat.Interop/NativeMethods.cs index 430f5a0..5f3234d 100644 --- a/clients/windows/VoiceCat.Interop/NativeMethods.cs +++ b/clients/windows/VoiceCat.Interop/NativeMethods.cs @@ -81,6 +81,10 @@ internal static partial class NativeMethods internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId, 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)] internal static partial VcResult vc_get_stream_audio_config(nint c, uint userId, uint streamId, out VcAudioConfigNative outCfg); diff --git a/clients/windows/VoiceCat.Interop/Structs.cs b/clients/windows/VoiceCat.Interop/Structs.cs index 8a14569..d9cd25c 100644 --- a/clients/windows/VoiceCat.Interop/Structs.cs +++ b/clients/windows/VoiceCat.Interop/Structs.cs @@ -156,6 +156,14 @@ internal struct VcStreamSummaryListNative public nuint Count; } +[StructLayout(LayoutKind.Sequential)] +internal struct VcRemoteStreamStateNative +{ + public float Gain; + public int Muted; + public int NoiseReduction; +} + [StructLayout(LayoutKind.Sequential)] internal struct VcAccountNative { diff --git a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs index cafafe0..dbfc612 100644 --- a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs +++ b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs @@ -208,6 +208,13 @@ public sealed class VoiceCatClient : IDisposable NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain, 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) { var r = NativeMethods.vc_get_stream_audio_config(_handle.DangerousGetHandle(), userId, diff --git a/core/include/voicecat.h b/core/include/voicecat.h index 41f7a3f..183de9d 100644 --- a/core/include/voicecat.h +++ b/core/include/voicecat.h @@ -319,6 +319,17 @@ typedef struct vc_stream_summary_list { size_t count; } 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. */ 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: * 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, - 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. * VC_ERR_INVALID_ARG if the user/stream isn't known. */ diff --git a/core/src/audio/audio_engine.cpp b/core/src/audio/audio_engine.cpp index f86279e..7562606 100644 --- a/core/src/audio/audio_engine.cpp +++ b/core/src/audio/audio_engine.cpp @@ -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) { std::lock_guard lk(streams_mu_); streams_.erase(ssrc); diff --git a/core/src/audio/audio_engine.h b/core/src/audio/audio_engine.h index 27dc2b6..648aaa2 100644 --- a/core/src/audio/audio_engine.h +++ b/core/src/audio/audio_engine.h @@ -158,6 +158,10 @@ class AudioEngine { // 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. 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); // Edge-triggered talk-state transitions since the last call (docs/voice.md §7: talk state diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index 7f29772..fa01073 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -1360,6 +1360,38 @@ vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, flo 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 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_audio_config* out) { 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) { 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::list_devices(vc_device_kind, vc_device_list* out) { out->items = nullptr; diff --git a/core/src/core/client.h b/core/src/core/client.h index 108e1b4..c05be18 100644 --- a/core/src/core/client.h +++ b/core/src/core/client.h @@ -55,6 +55,8 @@ struct vc_client { 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, 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); diff --git a/core/src/voicecat.cpp b/core/src/voicecat.cpp index b7af8dd..fa2d51d 100644 --- a/core/src/voicecat.cpp +++ b/core/src/voicecat.cpp @@ -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); } +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_audio_config* out) { if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; diff --git a/docs/voice.md b/docs/voice.md index 7c9457e..c596fee 100644 --- a/docs/voice.md +++ b/docs/voice.md @@ -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 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) Whether the mic transmits is decided locally by the **input gate**, and the client supports diff --git a/tests/test_m3_multistream.cpp b/tests/test_m3_multistream.cpp index aa5d42e..70205af 100644 --- a/tests/test_m3_multistream.cpp +++ b/tests/test_m3_multistream.cpp @@ -289,6 +289,28 @@ int main() { } { 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 ────────────────────────────────── // 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