From 6fe7bf01587f61a1cb621a8f7392dd4a5506e9bd Mon Sep 17 00:00:00 2001 From: Talon Date: Wed, 24 Jun 2026 14:29:39 +0200 Subject: [PATCH] feat: fix voice join/leave, channel edit defaults, channel-update stream restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS): 1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane. Previously the button only toggled the local mic — receiving was always on (gated by channel membership alone). Added a protocol-level voice subscription concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked by the SFU relay recipient filter, and core-client gating of remote-stream decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe on Leave. Text chat works regardless of voice subscription. 2. Channel edit dialog now shows the channel's actual current settings. The read struct vc_channel was missing sort_order and audio fields — only the write struct vc_channel_info had them. Extended vc_channel with both (additive, no ABI break), updated the session model and list_channels marshaling to populate them, and updated all three clients' edit callers to use actual channel info instead of hardcoded defaults. 3. Channel parameter updates now automatically restart everyone's streams. Previously editing a channel's audio config persisted and broadcast a ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are frozen at announce time. handle_channel_event now detects audio-config changes on the user's current channel and stop->starts each active local stream. The server reads the updated config on re-announce; peers wire up fresh decoders at the new ssrc. All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not yet compile-verified (Windows environment). --- PROGRESS.md | 32 +++ .../apple/Sources/VoiceCatCore/Enums.swift | 2 + .../Sources/VoiceCatCore/Marshaling.swift | 6 +- .../apple/Sources/VoiceCatCore/Models.swift | 12 +- .../Sources/VoiceCatCore/VoiceCatClient.swift | 10 + clients/apple/iOS/VoiceCatiOS/AppState.swift | 2 +- .../apple/iOS/VoiceCatiOS/SessionState.swift | 53 ++--- .../VoiceCatiOS/Views/ChannelEditView.swift | 14 +- .../iOS/VoiceCatiOS/Views/SettingsView.swift | 2 +- .../VoiceCatiOS/Views/VoiceControlsView.swift | 12 +- .../Windows/MainWindowController.swift | 49 ++++- .../windows/VoiceCat.App/Forms/MainForm.cs | 60 ++++-- clients/windows/VoiceCat.Interop/Enums.cs | 2 + .../windows/VoiceCat.Interop/Marshaling.cs | 7 +- clients/windows/VoiceCat.Interop/Models.cs | 7 +- .../windows/VoiceCat.Interop/NativeMethods.cs | 6 + clients/windows/VoiceCat.Interop/Structs.cs | 3 + .../VoiceCat.Interop/VoiceCatClient.cs | 6 + core/include/voicecat.h | 19 ++ core/proto/voicecat.proto | 12 ++ core/src/core/client.cpp | 193 ++++++++++++++++++ core/src/core/client.h | 28 +++ core/src/session/session.cpp | 20 ++ core/src/session/session.h | 16 ++ core/src/voicecat.cpp | 10 + docs/protocol.md | 3 + server/src/conn_session.cpp | 76 +++++++ server/src/conn_session.h | 8 + server/src/media_relay.cpp | 6 + server/src/session_registry.cpp | 13 +- server/src/session_registry.h | 3 + tests/test_channel_samplerate.cpp | 4 + tests/test_channel_user_list_abi.cpp | 10 + tests/test_dred_toggle.cpp | 7 + tests/test_external_pcm.cpp | 6 + tests/test_frame_ms_reframe.cpp | 4 + tests/test_m2_voice.cpp | 19 ++ tests/test_m3_multistream.cpp | 10 + tests/test_vad_ptt_devices.cpp | 10 + tests/test_voice_client_abi.cpp | 10 + 40 files changed, 701 insertions(+), 71 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index dfad11b..35b9f60 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,38 @@ up instantly. Newest status at the top. ## ▶ Where we left off / next action +- **Done (2026-06-24):** **Three bug fixes — voice join/leave, channel edit defaults, channel-update stream restart.** + 1. **Join/Leave Voice now truly subscribes/unsubscribes from the voice plane.** Previously + "Join Voice" only started the local mic — receiving was always on (gated by channel + membership alone). Added a protocol-level voice subscription concept: new + `SubscribeVoiceRequest`/`UnsubscribeVoiceRequest`/`VoiceSubscriptionResult` proto messages + (`core/proto/voicecat.proto`), `User.voice_subscribed` field, `vc_join_voice`/`vc_leave_voice` + C ABI functions (`core/include/voicecat.h`), `VC_EVENT_VOICE_STATE` event, server-side + `voice_subscribed_` flag on `ConnSession` checked by the SFU relay's recipient filter + (`SessionRegistry::find_channel_sessions` excludes non-subscribers; `MediaRelay::on_udp_frame` + also skips non-subscribed senders). The core client gates `sync_remote_streams` on + `voice_subscribed_`, tears down all remote decoders + stops local streams on leave, and + re-syncs from the session model on join. All three clients (Windows/macOS/iOS) rewired + their Join/Leave Voice button to call `joinVoice`+start mic / `leaveVoice`+core stops mic. + The configured input mode (PTT/VAD/AlwaysOn) takes effect on join — no extra mic button. + Text chat works regardless of voice subscription. **Apple clients not yet compile-verified + (Windows environment).** + 2. **Channel edit dialog now shows the channel's actual current settings.** The read struct + `vc_channel` (`voicecat.h`) was missing `sort_order` and `audio` fields — only the write + struct `vc_channel_info` had them. Extended `vc_channel` with both (additive, no ABI break), + updated the session model (`session::Channel`) and `apply_snapshot`/`apply_channel_event` + to populate them, and updated `vc_list_channels` marshaling. All three clients now build + the edit descriptor from the actual channel info instead of hardcoded defaults. + 3. **Channel parameter updates now automatically restart everyone's streams.** Previously + editing a channel's audio config (codec/bitrate/sample-rate/FEC/DTX/etc.) persisted and + broadcast a `ChannelEvent::UPDATED`, but no layer restarted streams — encoders/decoders + are frozen at announce time. `handle_channel_event` (`core/src/core/client.cpp`) now + detects audio-config changes on the user's current channel and calls + `restart_active_streams_for_channel`, which stop→starts each active local stream. The + server reads the updated channel config on re-announce, and peers' `sync_remote_streams` + wire up fresh decoders at the new ssrc. The `LocalStream` struct now retains the stream + label across restarts. No server or protocol change needed. + - **[ ] Soon — jitter buffer should measure REAL arrival jitter (RFC 3550), not sender timestamps.** `JitterBuffer::push` (`core/src/audio/audio_engine.cpp:84-108`) estimates jitter from `gap = ts - last_push_ts_`, where `ts` is the **sender's timestamp** — which is diff --git a/clients/apple/Sources/VoiceCatCore/Enums.swift b/clients/apple/Sources/VoiceCatCore/Enums.swift index 2f154c1..6dbb83e 100644 --- a/clients/apple/Sources/VoiceCatCore/Enums.swift +++ b/clients/apple/Sources/VoiceCatCore/Enums.swift @@ -133,6 +133,8 @@ public enum VoiceCatEventType: UInt32, Sendable, Equatable { case genericResult = 14 /// M5: reply to `requestAccountList()` — call `listAccounts()` to read. case accountList = 15 + /// Voice-plane subscription state. `u32a` = 1 (subscribed) or 0 (unsubscribed). + case voiceState = 16 public init(_ cValue: vc_event_type) { self = VoiceCatEventType(rawValue: cValue.rawValue) ?? .error diff --git a/clients/apple/Sources/VoiceCatCore/Marshaling.swift b/clients/apple/Sources/VoiceCatCore/Marshaling.swift index c0da7ce..e651d64 100644 --- a/clients/apple/Sources/VoiceCatCore/Marshaling.swift +++ b/clients/apple/Sources/VoiceCatCore/Marshaling.swift @@ -38,7 +38,8 @@ internal enum Marshaling { let c = items.advanced(by: i).pointee result.append(Channel(id: c.id, parentId: c.parent_id, name: string(c.name), topic: string(c.topic), passwordProtected: c.password_protected != 0, - maxUsers: c.max_users)) + maxUsers: c.max_users, sortOrder: c.sort_order, + audio: audioConfig(c.audio))) } vc_free_channel_list(&list) return result @@ -53,7 +54,8 @@ internal enum Marshaling { result.append(User(id: u.id, nickname: string(u.nickname), isGuest: u.is_guest != 0, channelId: u.channel_id, selfMicMuted: u.self_mic_muted != 0, selfDeafened: u.self_deafened != 0, serverMuted: u.server_muted != 0, - serverDeafened: u.server_deafened != 0)) + serverDeafened: u.server_deafened != 0, + voiceSubscribed: u.voice_subscribed != 0)) } vc_free_user_list(&list) return result diff --git a/clients/apple/Sources/VoiceCatCore/Models.swift b/clients/apple/Sources/VoiceCatCore/Models.swift index 1dc614d..6aa3803 100644 --- a/clients/apple/Sources/VoiceCatCore/Models.swift +++ b/clients/apple/Sources/VoiceCatCore/Models.swift @@ -16,11 +16,17 @@ public struct Channel: Sendable, Equatable, Identifiable { public let passwordProtected: Bool /// 0 = unlimited. public let maxUsers: UInt32 + public let sortOrder: UInt32 + /// Authoritative channel Opus params (docs/voice.md §3). Populated from the Channel proto + /// so the edit dialog can read back the current config. + public let audio: AudioConfig public init(id: UInt32, parentId: UInt32, name: String, topic: String, - passwordProtected: Bool, maxUsers: UInt32) { + passwordProtected: Bool, maxUsers: UInt32, sortOrder: UInt32, + audio: AudioConfig) { self.id = id; self.parentId = parentId; self.name = name; self.topic = topic self.passwordProtected = passwordProtected; self.maxUsers = maxUsers + self.sortOrder = sortOrder; self.audio = audio } } @@ -57,13 +63,15 @@ public struct User: Sendable, Equatable, Identifiable { public let selfDeafened: Bool public let serverMuted: Bool public let serverDeafened: Bool + public let voiceSubscribed: Bool public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32, selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool, - serverDeafened: Bool) { + serverDeafened: Bool, voiceSubscribed: Bool) { self.id = id; self.nickname = nickname; self.isGuest = isGuest; self.channelId = channelId self.selfMicMuted = selfMicMuted; self.selfDeafened = selfDeafened self.serverMuted = serverMuted; self.serverDeafened = serverDeafened + self.voiceSubscribed = voiceSubscribed } } diff --git a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift index ddbaecb..8a38621 100644 --- a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift +++ b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift @@ -256,6 +256,16 @@ public final class VoiceCatClient { VoiceCatResult(vc_leave_channel(handle)) } + @discardableResult + public func joinVoice() -> VoiceCatResult { + VoiceCatResult(vc_join_voice(handle)) + } + + @discardableResult + public func leaveVoice() -> VoiceCatResult { + VoiceCatResult(vc_leave_voice(handle)) + } + /// Pull the current channel tree. Re-call after `.channelList`/`.userJoined`/`.userLeft`/ /// `.userUpdated` events. The native list is freed inside this call — callers never /// manage native lifetime. diff --git a/clients/apple/iOS/VoiceCatiOS/AppState.swift b/clients/apple/iOS/VoiceCatiOS/AppState.swift index bd26d47..d26cb35 100644 --- a/clients/apple/iOS/VoiceCatiOS/AppState.swift +++ b/clients/apple/iOS/VoiceCatiOS/AppState.swift @@ -98,7 +98,7 @@ final class AppState { } func disconnect() { - session?.stopMicStream() + session?.leaveVoice() session?.client.disconnect() IOSAudioEngine.shared.stop() AudioSessionManager.shared.deactivateSession() diff --git a/clients/apple/iOS/VoiceCatiOS/SessionState.swift b/clients/apple/iOS/VoiceCatiOS/SessionState.swift index 798ff78..4d0acb0 100644 --- a/clients/apple/iOS/VoiceCatiOS/SessionState.swift +++ b/clients/apple/iOS/VoiceCatiOS/SessionState.swift @@ -20,6 +20,7 @@ struct ActivityEntry: Identifiable { struct VoiceState { var micActive = false + var voiceSubscribed = false var selfMuted = false var selfDeafened = false var serverMuted = false @@ -151,8 +152,6 @@ final class SessionState { addActivity("Sharing screen audio (\(channels == 2 ? "stereo" : "mono"))") break } - // A remote user started a stream — ensure the audio session is active so we can - // hear them even if we haven't joined voice ourselves. if ev.userId != selfUserId { do { try AudioSessionManager.shared.ensureSessionActive() @@ -163,7 +162,25 @@ final class SessionState { AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))") addActivity("Stream started (user \(ev.userId))") case .streamStopped: - addActivity("Stream stopped (user \(ev.userId))") + if ev.userId == selfUserId { + if ev.streamId == voiceState.localStreamId { + voiceState.localStreamId = 0 + voiceState.micActive = false + voiceState.level = 0 + } + } else { + addActivity("Stream stopped (user \(ev.userId))") + } + case .voiceState: + let subscribed = ev.u32a != 0 + voiceState.voiceSubscribed = subscribed + if subscribed { + doStartMicStream() + } else { + voiceState.micActive = false + voiceState.level = 0 + EventFeedback.shared.play(.voiceOff) + } case .joinResult: if ev.result == .ok { currentChannelId = ev.channelId @@ -234,12 +251,15 @@ final class SessionState { currentChannelId = 0 } - func startMicStream() { + func joinVoice() { AVAudioApplication.requestRecordPermission { [weak self] granted in DispatchQueue.main.async { guard let self else { return } if granted { - self.doStartMicStream() + let result = self.client.joinVoice() + if result != .ok { + self.addActivity("Failed to join voice: \(result.description)") + } } else { self.addActivity("Microphone permission denied — grant in Settings > Privacy > Microphone") } @@ -255,10 +275,6 @@ final class SessionState { return } - // Unified iOS path: the core is always external (set at connect via setExternalPlayback + - // every MIC stream external_feed), and `IOSAudioEngine` drives capture + playback. So the - // mic stream is just started with external_feed=true and the engine is told the mic is now - // active — no setExternalPlayback toggle, no audioRestart ordering, no VPIO/miniaudio fork. let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic", externalFeed: true) let (result, streamId) = client.startStream(desc) @@ -274,25 +290,14 @@ final class SessionState { if channels != 1 { client.setCaptureChannels(streamId: streamId, channels: channels) } - // Engage the mic: installs the input tap and (per preset) VPIO, in one engine rebuild. IOSAudioEngine.shared.startMic(streamId: streamId, channels: channels) } - func stopMicStream() { - // Disengage the mic (removes the tap + VPIO) but keep the engine running for any remaining - // remote audio. Then stop the core's MIC stream. The core stays external throughout — no - // setExternalPlayback toggle, no audioRestart. + func leaveVoice() { + if voiceState.screenStreamId != 0 { stopScreenShare() } + client.setPushToTalk(false) IOSAudioEngine.shared.stopMic() - if voiceState.localStreamId != 0 { - client.stopStream(voiceState.localStreamId) - voiceState.localStreamId = 0 - EventFeedback.shared.play(.voiceOff) - } - voiceState.micActive = false - voiceState.level = 0 - // Do NOT deactivate the AVAudioSession here — the user may still want to hear - // remote audio (other people talking). The session is deactivated only when - // disconnecting from the server (see AppState.disconnect / .disconnected event). + client.leaveVoice() } // MARK: - Screen audio share diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift index a414b77..c66e299 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/ChannelEditView.swift @@ -15,8 +15,7 @@ struct ChannelEditView: View { @State private var maxUsers = "0" @State private var sortOrder = "0" - // Audio (Opus). Note: the channel list does not carry the current audio config, so when - // editing an existing channel these start from the codec defaults (same as macOS/Windows). + // Audio (Opus) — populated from the channel's current config when editing. @State private var stereo = false @State private var bitrate = "64000" @State private var sampleRate = "48000" @@ -122,6 +121,17 @@ struct ChannelEditView: View { parentId = ch.parentId passwordProtected = ch.passwordProtected maxUsers = "\(ch.maxUsers)" + sortOrder = "\(ch.sortOrder)" + stereo = ch.audio.stereo + bitrate = "\(ch.audio.bitrateBps)" + sampleRate = "\(ch.audio.sampleRate)" + frameMs = ch.audio.frameMs + application = ch.audio.application + packetLoss = "\(ch.audio.expectedPacketLoss)" + complexity = ch.audio.complexity + fec = ch.audio.fec + dtx = ch.audio.dtx + dred = ch.audio.dred } private func save() { diff --git a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift index b310262..45db67e 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift @@ -294,7 +294,7 @@ struct SettingsView: View { // MARK: - Server Section("Server") { Button(role: .destructive) { - session.stopMicStream() + session.leaveVoice() appState.disconnect() } label: { Label("Disconnect", systemImage: "phone.down") diff --git a/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift index 2d9b295..c30d4d4 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift @@ -14,9 +14,9 @@ struct VoiceControlsView: View { } else { Button { if session.voiceState.micActive { - session.stopMicStream() + session.leaveVoice() } else { - session.startMicStream() + session.joinVoice() } } label: { Text(session.voiceState.micActive ? "Leave Voice" : "Join Voice") @@ -28,7 +28,7 @@ struct VoiceControlsView: View { .foregroundStyle(session.voiceState.micActive ? .green : .accentColor) } .disabled(session.currentChannelId == 0) - .accessibilityLabel(session.voiceState.micActive ? "Leave Voice — stop sending microphone audio" : "Join Voice — start sending microphone audio") + .accessibilityLabel(session.voiceState.micActive ? "Leave Voice" : "Join Voice") } // Level meter @@ -81,7 +81,7 @@ struct VoiceControlsView: View { // Disconnect Button(role: .destructive) { - session.stopMicStream() + session.leaveVoice() session.client.disconnect() } label: { Image(systemName: "phone.down.fill") @@ -115,13 +115,13 @@ private struct PTTButton: View { .updating($isPressing) { _, state, _ in state = true } .onChanged { _ in if !isPressing { return } - if !session.voiceState.micActive { session.startMicStream() } + if !session.voiceState.micActive { session.joinVoice() } session.setPushToTalk(true) UIImpactFeedbackGenerator(style: .medium).impactOccurred() } .onEnded { _ in session.setPushToTalk(false) - session.stopMicStream() + session.leaveVoice() } ) .accessibilityLabel("Push to talk, hold to transmit") diff --git a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift index c69b85c..9612370 100644 --- a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift +++ b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift @@ -497,7 +497,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { isGuest: true, channelId: event.channelId, selfMicMuted: false, selfDeafened: false, - serverMuted: false, serverDeafened: false) + serverMuted: false, serverDeafened: false, + voiceSubscribed: false) users[event.userId] = u refreshChannelTree(); refreshUserList() if event.channelId == currentChannelId && event.userId != selfUserId { @@ -540,7 +541,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { users[selfUserId] = User(id: self_.id, nickname: self_.nickname, isGuest: self_.isGuest, channelId: event.channelId, selfMicMuted: self_.selfMicMuted, selfDeafened: self_.selfDeafened, - serverMuted: self_.serverMuted, serverDeafened: self_.serverDeafened) + serverMuted: self_.serverMuted, serverDeafened: self_.serverDeafened, + voiceSubscribed: self_.voiceSubscribed) } refreshChannelTree(); refreshUserList(); updateStatusLabel() let name = channels.first(where: { $0.id == event.channelId })?.name ?? "Channel #\(event.channelId)" @@ -589,10 +591,18 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { addActivity("\(u.nickname) started \(kindStr) stream") case .streamStopped: - if let u = users[event.userId], u.channelId == currentChannelId { + if event.userId == selfUserId { + if event.streamId == micStreamId { + micStreamId = 0 + settingsWindowController?.resetLevel() + } + } else if let u = users[event.userId], u.channelId == currentChannelId { addActivity("\(u.nickname) stopped a stream") } + case .voiceState: + handleVoiceState(event) + case .disconnected: handleDisconnected(event) @@ -783,14 +793,27 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { @objc private func micToggleClicked() { if micStreamId == 0 { + let result = client.joinVoice() + if result != .ok { + addActivity("Failed to join voice: \(result)") + } + } else { + stopAuxStream() + if screenStreamId != 0 { stopScreenAudio() } + client.setPushToTalk(false) + client.leaveVoice() + } + } + + private func handleVoiceState(_ event: VoiceCatEvent) { + let subscribed = event.u32a != 0 + if subscribed { let (result, streamId) = client.startStream(StreamDescriptor(kind: .mic, deviceId: nil, label: "Microphone")) if result == .ok { micStreamId = streamId if let devId = selectedInputDeviceId { client.setInputDevice(streamId: streamId, deviceId: devId) } - // Stored on the stream before the announce round-trip completes, so the core's - // first capture-device open (ensure_audio_running) picks up the channel count. client.setCaptureChannels(streamId: streamId, channels: stereoMic ? 2 : 1) client.setInputMode(selectedInputMode) if selectedInputMode == .voiceActivation { @@ -803,14 +826,11 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { EventFeedback.shared.play(.voiceOn) NSAccessibility.post(element: logTextView, notification: .announcementRequested, userInfo: [.announcement: "Joined voice", .priority: NSAccessibilityPriorityLevel.medium]) - startAuxStream() // no-op unless the aux stream is enabled in settings + startAuxStream() } else { addActivity("Failed to start microphone: \(result)") } } else { - stopAuxStream() - client.setPushToTalk(false) - client.stopStream(micStreamId) micStreamId = 0 settingsWindowController?.resetLevel() setVoiceJoinedState(false) @@ -933,6 +953,15 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { } } + private func stopScreenAudio() { + guard screenStreamId != 0 else { return } + stopScreenCapture() + client.stopStream(screenStreamId) + screenStreamId = 0 + setShareScreenButton(active: false) + addActivity("Stopped sharing screen audio") + } + /// Announce the SCREEN_AUDIO stream with the chosen selection in hand. ScreenCaptureKit /// capture starts once the server's StreamAnnounceResult lands (the .streamStarted event), /// when the effective audio config — and thus the channel count — is known. See @@ -1449,7 +1478,7 @@ extension MainWindowController: NSMenuDelegate { let info = ChannelEdit(id: ch.id, parentId: ch.parentId, name: ch.name, topic: ch.topic, passwordProtected: ch.passwordProtected, password: nil, maxUsers: ch.maxUsers, - sortOrder: 0, audio: AudioConfig()) + sortOrder: ch.sortOrder, audio: ch.audio) let sheet = ChannelEditSheet(channels: channels, editing: info) sheet.onComplete = { [weak self] edited in guard let edited else { return } diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs index 03bb7c6..24fabcc 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs @@ -349,10 +349,21 @@ public partial class MainForm : Form HandleStreamStarted(ev); break; case VcEventType.StreamStopped: - if (_users.TryGetValue(ev.UserId, out var stUser) && + if (ev.UserId == _selfUserId) + { + if (ev.StreamId == _micStreamId) + { + _micStreamId = 0; + pbLevel.Value = 0; + } + } + else if (_users.TryGetValue(ev.UserId, out var stUser) && stUser.ChannelId == _currentChannelId) AddActivity($"{stUser.Nickname} stopped a stream"); break; + case VcEventType.VoiceState: + HandleVoiceState(ev); + break; case VcEventType.Disconnected: HandleDisconnected(ev); break; @@ -378,7 +389,7 @@ public partial class MainForm : Form private void HandleUserJoined(VoiceCatEvent ev) { var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId, - false, false, false, false); + false, false, false, false, false); _users[ev.UserId] = user; RefreshChannelTree(); RefreshUserList(); @@ -663,6 +674,32 @@ public partial class MainForm : Form private void BtnMicToggle_Click(object? sender, EventArgs e) { if (_micStreamId == 0) + { + var result = _client.JoinVoice(); + if (result != VcResult.Ok) + AddActivity($"Failed to join voice: {result}"); + } + else + { + StopAuxStream(); + if (_screenStreamId != 0) StopScreenAudio(); + _client.SetPushToTalk(false); + _client.LeaveVoice(); + } + } + + private void SetVoiceJoinedState(bool joined) + { + tsbJoinVoice.Text = joined ? "Leave Voice" : "Join Voice"; + _miJoinVoice.Text = joined ? "Leave &Voice" : "&Join Voice"; + chkMute.Enabled = joined; + chkDeafen.Enabled = joined; + } + + private void HandleVoiceState(VoiceCatEvent ev) + { + bool subscribed = ev.U32a != 0; + if (subscribed) { var (result, streamId) = _client.StartStream(VcStreamKind.Mic, "Microphone"); if (result == VcResult.Ok) @@ -680,7 +717,7 @@ public partial class MainForm : Form SetVoiceJoinedState(true); AddActivity("Joined voice — microphone active"); _feedback.PlaySound(SoundEvent.VoiceOn); - StartAuxStream(); // no-op unless the aux stream is enabled in settings + StartAuxStream(); } else { @@ -689,25 +726,12 @@ public partial class MainForm : Form } else { - StopAuxStream(); - _client.SetPushToTalk(false); - _client.StopStream(_micStreamId); - _micStreamId = 0; - pbLevel.Value = 0; SetVoiceJoinedState(false); AddActivity("Left voice"); _feedback.PlaySound(SoundEvent.VoiceOff); } } - private void SetVoiceJoinedState(bool joined) - { - tsbJoinVoice.Text = joined ? "Leave Voice" : "Join Voice"; - _miJoinVoice.Text = joined ? "Leave &Voice" : "&Join Voice"; - chkMute.Enabled = joined; - chkDeafen.Enabled = joined; - } - private void ApplySelfMute() => _client.SetSelfMute(chkMute.Checked, chkDeafen.Checked); @@ -1122,8 +1146,8 @@ public partial class MainForm : Form var editInfo = new ChannelEditInfo( channel.Id, channel.ParentId, channel.Name, channel.Topic, - channel.PasswordProtected, null, channel.MaxUsers, 0, - new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10, false)); + channel.PasswordProtected, null, channel.MaxUsers, channel.SortOrder, + channel.Audio); using var dlg = new ChannelEditDialog(_channels, editInfo); if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return; diff --git a/clients/windows/VoiceCat.Interop/Enums.cs b/clients/windows/VoiceCat.Interop/Enums.cs index 478e021..8fc9562 100644 --- a/clients/windows/VoiceCat.Interop/Enums.cs +++ b/clients/windows/VoiceCat.Interop/Enums.cs @@ -92,6 +92,8 @@ public enum VcEventType GenericResult = 14, /// M5: reply to VoiceCatClient.RequestAccountList — call ListAccounts() to read. AccountList = 15, + /// Voice-plane subscription state. u32a = 1 (subscribed) or 0 (unsubscribed). + VoiceState = 16, } /// diff --git a/clients/windows/VoiceCat.Interop/Marshaling.cs b/clients/windows/VoiceCat.Interop/Marshaling.cs index 265e568..8e937e3 100644 --- a/clients/windows/VoiceCat.Interop/Marshaling.cs +++ b/clients/windows/VoiceCat.Interop/Marshaling.cs @@ -38,7 +38,9 @@ internal static class Marshaling Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty, Marshal.PtrToStringUTF8(raw.Topic) ?? string.Empty, raw.PasswordProtected != 0, - raw.MaxUsers)); + raw.MaxUsers, + raw.SortOrder, + ToManaged(in raw.Audio))); } NativeMethods.vc_free_channel_list(ref native); return result; @@ -59,7 +61,8 @@ internal static class Marshaling raw.SelfMicMuted != 0, raw.SelfDeafened != 0, raw.ServerMuted != 0, - raw.ServerDeafened != 0)); + raw.ServerDeafened != 0, + raw.VoiceSubscribed != 0)); } NativeMethods.vc_free_user_list(ref native); return result; diff --git a/clients/windows/VoiceCat.Interop/Models.cs b/clients/windows/VoiceCat.Interop/Models.cs index 63774d5..39e2b08 100644 --- a/clients/windows/VoiceCat.Interop/Models.cs +++ b/clients/windows/VoiceCat.Interop/Models.cs @@ -9,7 +9,9 @@ public sealed record ChannelInfo( string Name, string Topic, bool PasswordProtected, - uint MaxUsers); + uint MaxUsers, + uint SortOrder, + AudioConfigInfo Audio); public sealed record ChannelEditInfo( uint Id, @@ -30,7 +32,8 @@ public sealed record UserInfo( bool SelfMicMuted, bool SelfDeafened, bool ServerMuted, - bool ServerDeafened); + bool ServerDeafened, + bool VoiceSubscribed); public sealed record PermissionsInfo( bool CanCreateTempChannel, diff --git a/clients/windows/VoiceCat.Interop/NativeMethods.cs b/clients/windows/VoiceCat.Interop/NativeMethods.cs index 8021b6f..757abce 100644 --- a/clients/windows/VoiceCat.Interop/NativeMethods.cs +++ b/clients/windows/VoiceCat.Interop/NativeMethods.cs @@ -54,6 +54,12 @@ internal static partial class NativeMethods [LibraryImport(LibName)] internal static partial VcResult vc_leave_channel(nint c); + [LibraryImport(LibName)] + internal static partial VcResult vc_join_voice(nint c); + + [LibraryImport(LibName)] + internal static partial VcResult vc_leave_voice(nint c); + // ── Local media streams ───────────────────────────────────────────────────────────────── [LibraryImport(LibName)] internal static partial VcResult vc_stream_start(nint c, in VcStreamDescNative desc, diff --git a/clients/windows/VoiceCat.Interop/Structs.cs b/clients/windows/VoiceCat.Interop/Structs.cs index 89964fe..273c955 100644 --- a/clients/windows/VoiceCat.Interop/Structs.cs +++ b/clients/windows/VoiceCat.Interop/Structs.cs @@ -116,6 +116,8 @@ internal struct VcChannelNative public IntPtr Topic; public int PasswordProtected; public uint MaxUsers; + public uint SortOrder; + public VcAudioConfigNative Audio; } [StructLayout(LayoutKind.Sequential)] @@ -136,6 +138,7 @@ internal struct VcUserNative public int SelfDeafened; public int ServerMuted; public int ServerDeafened; + public int VoiceSubscribed; } [StructLayout(LayoutKind.Sequential)] diff --git a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs index cc35210..c3fa02d 100644 --- a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs +++ b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs @@ -152,6 +152,12 @@ public sealed class VoiceCatClient : IDisposable public VcResult LeaveChannel() => NativeMethods.vc_leave_channel(_handle.DangerousGetHandle()); + public VcResult JoinVoice() => + NativeMethods.vc_join_voice(_handle.DangerousGetHandle()); + + public VcResult LeaveVoice() => + NativeMethods.vc_leave_voice(_handle.DangerousGetHandle()); + public List ListChannels() { NativeMethods.vc_list_channels(_handle.DangerousGetHandle(), out var native); diff --git a/core/include/voicecat.h b/core/include/voicecat.h index 968f4e2..3702aec 100644 --- a/core/include/voicecat.h +++ b/core/include/voicecat.h @@ -145,6 +145,10 @@ typedef enum vc_event_type { vc_delete_channel/vc_create_account/vc_reset_password/ vc_delete_account. */ VC_EVENT_ACCOUNT_LIST = 15, /* Reply to vc_list_accounts. */ + VC_EVENT_VOICE_STATE = 16, /* u32a = subscribed(0/1). Reply to vc_join_voice()/ + vc_leave_voice(), and also emitted when the server + changes your voice-subscription state. The user list + (vc_user) carries per-user voice_subscribed. */ } vc_event_type; /* TOFU server-identity classification (M4) — see VC_EVENT_SERVER_IDENTITY and @@ -292,6 +296,10 @@ typedef struct vc_channel { const char* topic; int password_protected; /* bool */ uint32_t max_users; /* 0 = unlimited */ + uint32_t sort_order; /* channel sort order */ + /* Authoritative channel Opus params (docs/voice.md §3). Populated from the Channel proto + * so the edit dialog can read back the current config without a separate round-trip. */ + vc_audio_config audio; } vc_channel; typedef struct vc_channel_list { @@ -308,6 +316,7 @@ typedef struct vc_user { int self_deafened; /* bool */ int server_muted; /* bool */ int server_deafened; /* bool */ + int voice_subscribed; /* bool — true when on the voice plane */ } vc_user; typedef struct vc_user_list { @@ -366,6 +375,16 @@ VC_API vc_result vc_join_channel(vc_client* c, uint32_t channel_id, const char* password /* nullable */); VC_API vc_result vc_leave_channel(vc_client* c); +/* ── Voice-plane subscription ─────────────────────────────────────────────── */ +/* Joining voice subscribes to the voice plane: the server starts relaying voice frames + * to you, and the core wires up remote-stream decoders so you hear other users. Leaving + * voice unsubscribes: the server stops relaying voice to you, the core tears down all + * remote decoders (playback stops), and any active local mic/screen/aux streams are + * stopped. Text chat is unaffected either way. Result arrives as VC_EVENT_VOICE_STATE + * (u32a = 1 for subscribed, 0 for unsubscribed). */ +VC_API vc_result vc_join_voice(vc_client* c); +VC_API vc_result vc_leave_voice(vc_client* c); + /* ── Local media streams (mic / screen audio / aux) ───────────────────────── */ VC_API vc_result vc_stream_start(vc_client* c, const vc_stream_desc* desc, uint32_t* out_stream_id); diff --git a/core/proto/voicecat.proto b/core/proto/voicecat.proto index b562129..ce5c47d 100644 --- a/core/proto/voicecat.proto +++ b/core/proto/voicecat.proto @@ -44,6 +44,9 @@ message Envelope { StreamStop stream_stop = 42; StreamStateUpdate stream_state = 43; UdpBinding udp_binding = 44; + SubscribeVoiceRequest subscribe_voice = 45; + UnsubscribeVoiceRequest unsubscribe_voice = 46; + VoiceSubscriptionResult voice_subscription_result = 47; // Text (50–59) TextMessage text_message = 50; @@ -120,6 +123,7 @@ message User { bool server_muted = 7; repeated StreamInfo streams = 8; bool server_deafened = 9; // M5: server-imposed deafen + bool voice_subscribed = 10; // true when the user is on the voice plane (hears + can send) } message Permissions { @@ -216,6 +220,14 @@ message StreamStop { uint32 stream_id = 1; } message StreamStateUpdate { uint32 user_id = 1; uint32 stream_id = 2; bool muted = 3; bool talking = 4; } message UdpBinding { bytes udp_token = 1; bool ack = 2; } +// Voice-plane subscription. Joining voice = you can hear and send voice in your channel; +// leaving voice = the server stops relaying voice frames to you and you stop receiving. +// Text chat is unaffected. The server toggles a per-session flag checked by the SFU relay's +// recipient filter (docs/protocol.md §5). Result arrives as voice_subscription_result. +message SubscribeVoiceRequest {} +message UnsubscribeVoiceRequest {} +message VoiceSubscriptionResult { bool ok = 1; string error = 2; bool subscribed = 3; } + // ── Text (ephemeral — server does not persist history, docs/protocol.md §5) ────── message TextMessage { TextScope scope = 1; diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index c0a7095..df87959 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -500,6 +500,9 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) { case voicecat::v1::Envelope::kStreamAnnounceResult: handle_stream_announce_result(env.request_id(), env.stream_announce_result()); break; + case voicecat::v1::Envelope::kVoiceSubscriptionResult: + handle_voice_subscription_result(env.voice_subscription_result()); + break; case voicecat::v1::Envelope::kPong: { // Correlate the echoed nonce to measure RTT (docs/protocol.md §7). uint64_t nonce = env.pong().nonce(); @@ -618,10 +621,44 @@ void vc_client::handle_server_state(const voicecat::v1::ServerStateSnapshot& sna } void vc_client::handle_channel_event(const voicecat::v1::ChannelEvent& ce) { + bool audio_changed = false; + uint32_t updated_channel_id = 0; { std::lock_guard lk(session_model_mu_); + + // Detect audio-config changes on our current channel BEFORE apply_channel_event + // overwrites the stored config. Encoders/decoders are frozen at StreamAnnounceResult + // time (docs/voice.md §3), so a channel audio change requires restarting active + // local streams to pick up the new params. + if (ce.kind() == voicecat::v1::ChannelEvent::UPDATED) { + updated_channel_id = ce.channel().id(); + const auto* self_user = session_model_.find_user(self_user_id_); + if (self_user && self_user->channel_id == updated_channel_id) { + const auto* old_ch = session_model_.find_channel(updated_channel_id); + const auto& na = ce.channel().audio(); + if (old_ch) { + audio_changed = + old_ch->audio_sample_rate != (na.sample_rate() ? na.sample_rate() : 48000) || + old_ch->audio_frame_ms != (na.frame_ms() ? na.frame_ms() : 20) || + old_ch->audio_mode != static_cast(na.mode()) || + old_ch->audio_bitrate_bps != na.bitrate_bps() || + old_ch->audio_application != static_cast(na.application()) || + old_ch->audio_fec != na.fec() || + old_ch->audio_expected_packet_loss != na.expected_packet_loss() || + old_ch->audio_dtx != na.dtx() || + old_ch->audio_complexity != (na.complexity() ? na.complexity() : 10) || + old_ch->audio_dred != na.dred(); + } + } + } + session_model_.apply_channel_event(ce); } + + if (audio_changed) { + restart_active_streams_for_channel(updated_channel_id); + } + // Same "go look" signal vc_list_channels' callers already poll on after the initial // snapshot — see voicecat.h's VC_EVENT_CHANNEL_LIST doc comment. vc_event ev{}; @@ -1215,6 +1252,10 @@ void vc_client::ensure_audio_running() { void vc_client::sync_remote_streams(const voicecat::v1::User& user) { if (user.id() == self_user_id_) return; + // Don't wire up remote decoders when not on the voice plane — the server isn't relaying + // voice to us, and decoders would sit idle consuming memory. resync_remote_streams() + // wires them up on voice join. + if (!voice_subscribed_.load(std::memory_order_acquire)) return; std::vector current_ssrcs; for (const auto& si : user.streams()) current_ssrcs.push_back(si.ssrc()); @@ -1285,6 +1326,7 @@ vc_result vc_client::stream_start(const vc_stream_desc& desc, uint32_t* out_stre ls.stream_id = sid; ls.pending = true; ls.external_feed = (desc.external_feed != 0); + ls.label = desc.label ? desc.label : ""; if (out_stream_id) *out_stream_id = sid; req_id = next_req_id_++; @@ -1441,6 +1483,141 @@ vc_client::LocalStream* vc_client::find_local_stream_by_id(uint32_t stream_id) { return nullptr; } +void vc_client::restart_active_streams_for_channel(uint32_t channel_id) { + // Collect active stream info under the lock, then stop→start outside the lock (stream_stop + // and stream_start both acquire local_streams_mu_). capture_device_id and capture_channels + // survive the restart: stream_stop doesn't clear them, and stream_start reuses the existing + // LocalStream entry (auto& ls = local_streams_[kind]) without resetting them. + struct ActiveStreamInfo { + int kind; + std::string label; + int external_feed; + uint32_t stream_id; + }; + std::vector active; + { + std::lock_guard lk(local_streams_mu_); + for (auto& [kind, ls] : local_streams_) { + if (!ls.active.load(std::memory_order_acquire)) continue; + active.push_back({kind, ls.label, ls.external_feed ? 1 : 0, ls.stream_id}); + } + } + + for (const auto& info : active) { + stream_stop(info.stream_id); + vc_stream_desc desc{}; + desc.kind = static_cast(info.kind); + desc.device_id = nullptr; // default — capture_device_id is retained on the LocalStream + desc.label = info.label.c_str(); + desc.external_feed = info.external_feed; + uint32_t new_sid = 0; + stream_start(desc, &new_sid); + } +} + +// ── Voice-plane subscription ────────────────────────────────────────────────── + +vc_result vc_client::join_voice() { + if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; + voicecat::v1::Envelope req; + req.set_request_id(next_req_id_++); + req.mutable_subscribe_voice(); + queue_envelope(req); + return VC_OK; +} + +vc_result vc_client::leave_voice() { + if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; + voicecat::v1::Envelope req; + req.set_request_id(next_req_id_++); + req.mutable_unsubscribe_voice(); + queue_envelope(req); + return VC_OK; +} + +void vc_client::handle_voice_subscription_result( + const voicecat::v1::VoiceSubscriptionResult& msg) { + bool subscribed = msg.subscribed(); + voice_subscribed_.store(subscribed, std::memory_order_release); + + if (subscribed) { + // Wire up remote-stream decoders for all users already in the session model. + // While we were unsubscribed, sync_remote_streams was gated off, so no decoders + // exist — resync from the current session state. + resync_remote_streams(); + } else { + // Stop all active local streams (emits VC_EVENT_STREAM_STOPPED for each) and tear + // down every remote decoder so playback ceases. The server has already stopped + // relaying voice to us. + stop_all_local_streams(); + clear_all_remote_streams(); + } + + vc_event ev{}; + ev.type = VC_EVENT_VOICE_STATE; + ev.u32a = subscribed ? 1 : 0; + emit(ev); +} + +void vc_client::resync_remote_streams() { + bool any_added = false; + { + std::lock_guard sm_lk(session_model_mu_); + std::lock_guard rs_lk(remote_streams_mu_); + for (const auto& u : session_model_.users()) { + if (u.id == self_user_id_) continue; + for (const auto& s : u.streams) { + if (remote_streams_.count(s.ssrc)) continue; + remote_streams_[s.ssrc] = {u.id, s.stream_id}; + + voicecat::v1::AudioConfig audio; + audio.set_sample_rate(s.sample_rate); + audio.set_frame_ms(s.frame_ms); + audio.set_mode(static_cast(s.mode)); + audio.set_bitrate_bps(s.bitrate_bps); + audio.set_application(static_cast(s.application)); + audio.set_fec(s.fec); + audio.set_expected_packet_loss(s.expected_packet_loss); + audio.set_dtx(s.dtx); + audio.set_complexity(s.complexity); + audio.set_dred(s.dred); + + voicecat::codec::OpusParams p = opus_params_from_audio_config(audio); + bool is_voice = (s.kind == static_cast(voicecat::v1::STREAM_MIC)); + audio_engine_.init_recv_stream(s.ssrc, p, u.id, s.stream_id, is_voice); + bool muted = self_deafened_.load(std::memory_order_acquire) || + server_deafened_.load(std::memory_order_acquire); + audio_engine_.set_stream_mute(s.ssrc, muted); + any_added = true; + } + } + } + if (any_added) ensure_audio_running(); +} + +void vc_client::clear_all_remote_streams() { + std::lock_guard lk(remote_streams_mu_); + for (auto& [ssrc, info] : remote_streams_) { + audio_engine_.remove_stream(ssrc); + } + remote_streams_.clear(); +} + +void vc_client::stop_all_local_streams() { + std::vector active_ids; + { + std::lock_guard lk(local_streams_mu_); + for (auto& [kind, ls] : local_streams_) { + if (ls.active.load(std::memory_order_acquire)) { + active_ids.push_back(ls.stream_id); + } + } + } + for (uint32_t sid : active_ids) { + stream_stop(sid); + } +} + vc_result vc_client::set_input_device(uint32_t stream_id, const char* device_id) { int kind = -1; { @@ -1734,6 +1911,19 @@ vc_result vc_client::list_channels(vc_channel_list* out) { items[i].topic = topic; items[i].password_protected = ch.password_protected ? 1 : 0; items[i].max_users = ch.max_users; + items[i].sort_order = ch.sort_order; + auto& ac = items[i].audio; + ac.codec = 0; // OPUS + ac.mode = ch.audio_mode; + ac.sample_rate = ch.audio_sample_rate; + ac.bitrate_bps = ch.audio_bitrate_bps; + ac.frame_ms = ch.audio_frame_ms; + ac.application = ch.audio_application; + ac.fec = ch.audio_fec ? 1 : 0; + ac.expected_packet_loss = ch.audio_expected_packet_loss; + ac.dtx = ch.audio_dtx ? 1 : 0; + ac.complexity = ch.audio_complexity; + ac.dred = ch.audio_dred ? 1 : 0; } out->items = items; out->count = channels.size(); @@ -1756,6 +1946,7 @@ vc_result vc_client::list_users(vc_user_list* out) { items[i].self_deafened = u.self_deafened ? 1 : 0; items[i].server_muted = u.server_muted ? 1 : 0; items[i].server_deafened = u.server_deafened ? 1 : 0; + items[i].voice_subscribed = u.voice_subscribed ? 1 : 0; } out->items = items; out->count = users.size(); @@ -2036,6 +2227,8 @@ vc_result vc_client::authenticate_guest(const char*) { return VC_ERR_NOT_ vc_result vc_client::authenticate_user(const char*, const char*) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::join_channel(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::leave_channel() { return VC_ERR_NOT_IMPLEMENTED; } +vc_result vc_client::join_voice() { return VC_ERR_NOT_IMPLEMENTED; } +vc_result vc_client::leave_voice() { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::stream_stop(uint32_t) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; } diff --git a/core/src/core/client.h b/core/src/core/client.h index b705110..d9bf0b1 100644 --- a/core/src/core/client.h +++ b/core/src/core/client.h @@ -46,6 +46,9 @@ struct vc_client { vc_result join_channel(uint32_t channel_id, const char* password); vc_result leave_channel(); + vc_result join_voice(); + vc_result leave_voice(); + vc_result stream_start(const vc_stream_desc& desc, uint32_t* out_stream_id); vc_result stream_stop(uint32_t stream_id); vc_result set_input_device(uint32_t stream_id, const char* device_id); @@ -161,6 +164,11 @@ struct vc_client { uint64_t server_session_id_{0}; std::atomic next_req_id_{1}; + // Voice-plane subscription state. When false, the server does not relay voice frames to + // us, and we do not wire up remote-stream decoders (sync_remote_streams is gated on this). + // Toggled by vc_join_voice / vc_leave_voice; confirmed via VoiceSubscriptionResult. + std::atomic voice_subscribed_{false}; + // Keepalive: client sends a Ping every ~15s (docs/protocol.md §7) so the server's // last_seen stays fresh and the reaper doesn't drop us. Pong echoes the nonce, which // we correlate to measure RTT. The read loop's 50ms TLS timeout means it spins fast @@ -265,6 +273,11 @@ struct vc_client { // time and checked in handle_stream_announce_result / stream_stop. bool external_feed = false; + // Stream label (from vc_stream_desc.label at stream_start time). Retained so the + // channel-update stream restart (restart_active_streams_for_channel) can re-announce + // with the same label without the caller's involvement. + std::string label; + // Reframe buffer: the AudioEngine clock is fixed at 48 kHz / 20 ms, so capture/feed // always delivers 960-sample frames — but the channel's frame_ms (docs/voice.md §3) // can be 2.5…60 ms, so the encoder needs frame_samples per call (480 @10ms, 1920 @40ms, @@ -344,12 +357,27 @@ struct vc_client { void handle_server_state(const voicecat::v1::ServerStateSnapshot& snap); void handle_user_event(const voicecat::v1::UserEvent& ue); void handle_channel_event(const voicecat::v1::ChannelEvent& ce); + // Called from handle_channel_event when the current channel's audio config changed: + // stop→start every active local stream so the new Opus params take effect (encoders are + // frozen at StreamAnnounceResult time — docs/voice.md §3). capture_device_id and + // capture_channels survive the restart (stream_stop doesn't clear them; stream_start + // reuses the existing LocalStream entry). The server reads the updated channel config + // on re-announce and returns new effective_audio; peers' sync_remote_streams wire up + // fresh decoders at the new ssrc. + void restart_active_streams_for_channel(uint32_t channel_id); void handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg); void handle_text_message(const voicecat::v1::TextMessage& msg); void handle_disconnect(const voicecat::v1::Disconnect& msg); void handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg); void handle_stream_announce_result(uint64_t req_id, const voicecat::v1::StreamAnnounceResult& msg); + void handle_voice_subscription_result(const voicecat::v1::VoiceSubscriptionResult& msg); + // Wire up remote-stream decoders for all users in the session model (used on voice join). + void resync_remote_streams(); + // Tear down all remote-stream decoders and clear remote_streams_ (used on voice leave). + void clear_all_remote_streams(); + // Stop every active local stream (used on voice leave — emits STREAM_STOPPED for each). + void stop_all_local_streams(); // ── M2: UDP / media helpers ────────────────────────────────────────────────── // Kicks off TCP UdpBinding request; called once after a successful AuthResult. diff --git a/core/src/session/session.cpp b/core/src/session/session.cpp index a964db1..0644b28 100644 --- a/core/src/session/session.cpp +++ b/core/src/session/session.cpp @@ -26,6 +26,20 @@ std::pair SessionModel::find_user_by_ssrc(uint32_t s #ifdef VOICECAT_HAS_NET namespace { + +void copy_channel_audio(Channel& ch, const voicecat::v1::AudioConfig& a) { + ch.audio_sample_rate = a.sample_rate() ? a.sample_rate() : 48000; + ch.audio_frame_ms = a.frame_ms() ? a.frame_ms() : 20; + ch.audio_mode = static_cast(a.mode()); + ch.audio_bitrate_bps = a.bitrate_bps(); + ch.audio_application = static_cast(a.application()); + ch.audio_fec = a.fec(); + ch.audio_expected_packet_loss = a.expected_packet_loss(); + ch.audio_dtx = a.dtx(); + ch.audio_complexity = a.complexity(); + ch.audio_dred = a.dred(); +} + std::vector copy_streams( const google::protobuf::RepeatedPtrField& src) { std::vector out; @@ -62,6 +76,8 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap) ch.topic = pb.topic(); ch.password_protected = pb.password_protected(); ch.max_users = pb.max_users(); + ch.sort_order = static_cast(pb.order()); + copy_channel_audio(ch, pb.audio()); channels_.push_back(std::move(ch)); } @@ -76,6 +92,7 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap) u.self_deafened = pb.self_deafened(); u.server_muted = pb.server_muted(); u.server_deafened = pb.server_deafened(); + u.voice_subscribed = pb.voice_subscribed(); u.streams = copy_streams(pb.streams()); users_.push_back(std::move(u)); } @@ -95,6 +112,7 @@ void SessionModel::apply_user_event(const voicecat::v1::UserEvent& ev) { u.self_deafened = pb.self_deafened(); u.server_muted = pb.server_muted(); u.server_deafened = pb.server_deafened(); + u.voice_subscribed = pb.voice_subscribed(); u.streams = copy_streams(pb.streams()); auto it = std::find_if(users_.begin(), users_.end(), @@ -122,6 +140,8 @@ void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) { ch.topic = pb.topic(); ch.password_protected = pb.password_protected(); ch.max_users = pb.max_users(); + ch.sort_order = static_cast(pb.order()); + copy_channel_audio(ch, pb.audio()); auto it = std::find_if(channels_.begin(), channels_.end(), [&](const Channel& x) { return x.id == ch.id; }); diff --git a/core/src/session/session.h b/core/src/session/session.h index 90a5f94..ecf39b7 100644 --- a/core/src/session/session.h +++ b/core/src/session/session.h @@ -25,6 +25,21 @@ struct Channel { std::string topic; bool password_protected{false}; uint32_t max_users{0}; + uint32_t sort_order{0}; + // Authoritative channel AudioConfig (docs/voice.md §3) — mirrors + // voicecat::v1::AudioConfig field-for-field, same flat shape as Stream below. Populated + // from ServerStateSnapshot / ChannelEvent so the channel-edit dialog can read back the + // current config (the vc_channel read struct now carries these too). + uint32_t audio_sample_rate{48000}; + uint32_t audio_frame_ms{20}; + uint32_t audio_mode{0}; // 0 = mono, 1 = stereo + uint32_t audio_bitrate_bps{0}; + uint32_t audio_application{0}; // 0=VOIP, 1=AUDIO, 2=LOWDELAY + bool audio_fec{false}; + uint32_t audio_expected_packet_loss{0}; + bool audio_dtx{false}; + uint32_t audio_complexity{0}; + bool audio_dred{false}; }; struct Stream { @@ -57,6 +72,7 @@ struct User { bool self_deafened{false}; bool server_muted{false}; bool server_deafened{false}; + bool voice_subscribed{false}; std::vector streams; }; diff --git a/core/src/voicecat.cpp b/core/src/voicecat.cpp index 6770377..179f0f0 100644 --- a/core/src/voicecat.cpp +++ b/core/src/voicecat.cpp @@ -82,6 +82,16 @@ vc_result vc_leave_channel(vc_client* c) { return c->leave_channel(); } +vc_result vc_join_voice(vc_client* c) { + if (c == nullptr) return VC_ERR_INVALID_ARG; + return c->join_voice(); +} + +vc_result vc_leave_voice(vc_client* c) { + if (c == nullptr) return VC_ERR_INVALID_ARG; + return c->leave_voice(); +} + vc_result vc_stream_start(vc_client* c, const vc_stream_desc* desc, uint32_t* out_stream_id) { if (c == nullptr || desc == nullptr) return VC_ERR_INVALID_ARG; return c->stream_start(*desc, out_stream_id); diff --git a/docs/protocol.md b/docs/protocol.md index ecd91e0..e542383 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -82,6 +82,9 @@ message Envelope { StreamStop stream_stop = 42; StreamStateUpdate stream_state = 43; // talking/muted indicator UdpBinding udp_binding = 44; // token to bind the UDP 5-tuple + SubscribeVoiceRequest subscribe_voice = 45; // join the voice plane + UnsubscribeVoiceRequest unsubscribe_voice = 46; // leave the voice plane + VoiceSubscriptionResult voice_subscription_result = 47; // ack with subscribed flag // ── Text ────────────────────────────────────────────── TextMessage text_message = 50; diff --git a/server/src/conn_session.cpp b/server/src/conn_session.cpp index f4a615f..f868c51 100644 --- a/server/src/conn_session.cpp +++ b/server/src/conn_session.cpp @@ -111,6 +111,14 @@ void ConnSession::on_frame(std::vector frame) { if (st == State::Authenticated) handle_stream_stop(env.stream_stop()); break; + case voicecat::v1::Envelope::kSubscribeVoice: + if (st == State::Authenticated) + handle_subscribe_voice(env.request_id()); + break; + case voicecat::v1::Envelope::kUnsubscribeVoice: + if (st == State::Authenticated) + handle_unsubscribe_voice(env.request_id()); + break; // ── M5 moderation / admin ───────────────────────────────────────────── case voicecat::v1::Envelope::kKick: @@ -521,6 +529,14 @@ void ConnSession::handle_udp_binding(uint64_t req_id, const voicecat::v1::UdpBin void ConnSession::handle_stream_announce(uint64_t req_id, const voicecat::v1::StreamAnnounce& msg) { + if (!voice_subscribed_.load(std::memory_order_acquire)) { + auto env = make_env(req_id); + auto* res = env.mutable_stream_announce_result(); + res->set_ok(false); + res->set_error("not subscribed to voice — call vc_join_voice first"); + send_envelope(env); + return; + } uint32_t ssrc = registry_->assign_ssrc(session_id_); uint32_t stream_id = next_stream_id_++; @@ -595,6 +611,66 @@ void ConnSession::handle_stream_stop(const voicecat::v1::StreamStop& msg) { } } +void ConnSession::handle_subscribe_voice(uint64_t req_id) { + voice_subscribed_.store(true, std::memory_order_release); + registry_->set_user_voice_subscribed(user_id_.load(), true); + + // Reply with the result. + auto env = make_env(req_id); + auto* res = env.mutable_voice_subscription_result(); + res->set_ok(true); + res->set_subscribed(true); + send_envelope(env); + + // Broadcast the updated user proto so peers see voice_subscribed = true. + if (auto updated_user = registry_->user_snapshot_user(user_id_.load())) { + auto bcast = make_env(); + auto* ue = bcast.mutable_user_event(); + ue->set_kind(voicecat::v1::UserEvent::UPDATED); + *ue->mutable_user() = *updated_user; + registry_->broadcast(bcast, /*exclude*/ 0); + } +} + +void ConnSession::handle_unsubscribe_voice(uint64_t req_id) { + voice_subscribed_.store(false, std::memory_order_release); + + // Clear all the user's streams so peers stop receiving from them. Each clear broadcasts + // a UserEvent::UPDATED; we collect the ids first to avoid mutating announced_stream_ids_ + // while iterating. + auto stream_ids = announced_stream_ids_; + for (uint32_t sid : stream_ids) { + auto it = std::find(announced_stream_ids_.begin(), announced_stream_ids_.end(), sid); + if (it != announced_stream_ids_.end()) announced_stream_ids_.erase(it); + auto updated = registry_->clear_user_stream(user_id_.load(), sid); + if (updated) { + auto bcast = make_env(); + auto* ue = bcast.mutable_user_event(); + ue->set_kind(voicecat::v1::UserEvent::UPDATED); + *ue->mutable_user() = *updated; + registry_->broadcast(bcast, /*exclude*/ 0); + } + } + + registry_->set_user_voice_subscribed(user_id_.load(), false); + + // Reply with the result. + auto env = make_env(req_id); + auto* res = env.mutable_voice_subscription_result(); + res->set_ok(true); + res->set_subscribed(false); + send_envelope(env); + + // Broadcast the updated user proto so peers see voice_subscribed = false. + if (auto updated_user = registry_->user_snapshot_user(user_id_.load())) { + auto bcast = make_env(); + auto* ue = bcast.mutable_user_event(); + ue->set_kind(voicecat::v1::UserEvent::UPDATED); + *ue->mutable_user() = *updated_user; + registry_->broadcast(bcast, /*exclude*/ 0); + } +} + // ── M5 handlers ────────────────────────────────────────────────────────────── void ConnSession::handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg) { diff --git a/server/src/conn_session.h b/server/src/conn_session.h index 9f08d49..8475207 100644 --- a/server/src/conn_session.h +++ b/server/src/conn_session.h @@ -82,6 +82,7 @@ class ConnSession : public std::enable_shared_from_this { State state() const { return state_.load(); } uint64_t session_id() const { return session_id_; } uint32_t user_id() const { return user_id_; } + bool voice_subscribed() const { return voice_subscribed_.load(std::memory_order_acquire); } // Keepalive: bump last_seen to "now" on any inbound activity (TCP frame or UDP voice // frame). The server's reaper sweeps sessions whose last_seen is older than the @@ -105,6 +106,8 @@ class ConnSession : public std::enable_shared_from_this { void handle_stream_announce(uint64_t req_id, const voicecat::v1::StreamAnnounce& msg); void handle_stream_stop(const voicecat::v1::StreamStop& msg); void handle_leave_channel(); + void handle_subscribe_voice(uint64_t req_id); + void handle_unsubscribe_voice(uint64_t req_id); // M5 handlers void handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg); @@ -172,6 +175,11 @@ class ConnSession : public std::enable_shared_from_this { // M5: permissions granted at auth time (server-side authority). voicecat::v1::Permissions permissions_; + + // Voice-plane subscription. When false, the SFU relay excludes this session from the + // recipient set (find_channel_sessions), and handle_stream_announce rejects new streams. + // Toggled by SubscribeVoiceRequest / UnsubscribeVoiceRequest. + std::atomic voice_subscribed_{false}; }; } // namespace voicecat::server diff --git a/server/src/media_relay.cpp b/server/src/media_relay.cpp index 5f3d3a5..c138806 100644 --- a/server/src/media_relay.cpp +++ b/server/src/media_relay.cpp @@ -81,6 +81,12 @@ void MediaRelay::on_udp_frame(const uint8_t* data, size_t len, auto sender_session = registry_->find_by_udp_endpoint(sender); if (!sender_session) { note_drop("unmapped-endpoint"); return; } + // Defense in depth: don't relay voice from sessions not on the voice plane. + // handle_stream_announce already rejects announces from non-subscribers, so a + // non-subscribed session shouldn't have any active streams — but this catches any + // edge case (e.g. a stream announced just before unsubscribing). + if (!sender_session->voice_subscribed()) { note_drop("not-subscribed"); return; } + auto* recv_crypto = sender_session->recv_crypto(); if (!recv_crypto) { note_drop("no-recv-crypto"); return; } diff --git a/server/src/session_registry.cpp b/server/src/session_registry.cpp index 789f24c..28fa92a 100644 --- a/server/src/session_registry.cpp +++ b/server/src/session_registry.cpp @@ -145,6 +145,13 @@ bool SessionRegistry::set_user_channel(uint32_t user_id, uint32_t channel_id) { return true; } +void SessionRegistry::set_user_voice_subscribed(uint32_t user_id, bool subscribed) { + std::unique_lock lk(mu_); + auto it = users_.find(user_id); + if (it == users_.end()) return; + it->second.proto.set_voice_subscribed(subscribed); +} + std::vector SessionRegistry::channel_snapshot() const { std::shared_lock lk(mu_); std::vector result; @@ -492,7 +499,11 @@ std::vector> SessionRegistry::find_channel_sessions if (entry.session_id == exclude_session_id) continue; auto sit = sessions_.find(entry.session_id); if (sit == sessions_.end()) continue; - if (auto sess = sit->second.lock()) result.push_back(sess); + if (auto sess = sit->second.lock()) { + // Only relay voice to sessions that are on the voice plane. + if (!sess->voice_subscribed()) continue; + result.push_back(sess); + } } return result; } diff --git a/server/src/session_registry.h b/server/src/session_registry.h index 51b1bf9..a79ea9f 100644 --- a/server/src/session_registry.h +++ b/server/src/session_registry.h @@ -75,6 +75,9 @@ class SessionRegistry { // Move a user to a channel. Returns false if channel doesn't exist. bool set_user_channel(uint32_t user_id, uint32_t channel_id); + // Set the user's voice-plane subscription flag on their proto (broadcast-ready). + void set_user_voice_subscribed(uint32_t user_id, bool subscribed); + // Snapshot for ServerStateSnapshot message. std::vector channel_snapshot() const; std::vector user_snapshot() const; diff --git a/tests/test_channel_samplerate.cpp b/tests/test_channel_samplerate.cpp index e2a4e3b..b0ac596 100644 --- a/tests/test_channel_samplerate.cpp +++ b/tests/test_channel_samplerate.cpp @@ -46,6 +46,7 @@ struct EventStore { bool channel_list_received{false}; bool generic_result_received{false}; bool generic_ok{false}; + bool voice_subscribed{false}; std::vector> streams_started; // (user_id, stream_id) vc_client* client{nullptr}; const char* label{nullptr}; @@ -61,6 +62,7 @@ static void on_event(void* user, const vc_event* ev) { s->self_user_id = ev->user_id; break; case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break; + case VC_EVENT_VOICE_STATE: s->voice_subscribed = (ev->u32a == 1); break; case VC_EVENT_GENERIC_RESULT: s->generic_result_received = true; s->generic_ok = (ev->result == VC_OK); @@ -90,6 +92,8 @@ static bool connect_guest(vc_client*& client, const char* name, const char* labe if (vc_authenticate_guest(client, name) != VC_OK) return false; if (!wait_for(ev, [](EventStore& s) { return s.auth_ok; }, 8000)) return false; if (!wait_for(ev, [](EventStore& s) { return s.channel_list_received; }, 3000)) return false; + if (vc_join_voice(client) != VC_OK) return false; + if (!wait_for(ev, [](EventStore& s) { return s.voice_subscribed; }, 5000)) return false; return true; } diff --git a/tests/test_channel_user_list_abi.cpp b/tests/test_channel_user_list_abi.cpp index c6c60b4..58e11fc 100644 --- a/tests/test_channel_user_list_abi.cpp +++ b/tests/test_channel_user_list_abi.cpp @@ -59,6 +59,7 @@ struct EventStore { bool channel_list_received{false}; std::vector stream_events; std::vector join_results; + bool voice_subscribed{false}; const char* label{nullptr}; vc_client* client{nullptr}; @@ -78,6 +79,9 @@ static void on_event(void* user, const vc_event* ev) { case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break; + case VC_EVENT_VOICE_STATE: + s->voice_subscribed = (ev->u32a == 1); + break; case VC_EVENT_STREAM_STARTED: s->stream_events.push_back({true, ev->user_id, ev->stream_id}); break; @@ -195,6 +199,9 @@ static void test_live_channel_user_list() { CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000)); CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000)); + CHECK(vc_join_voice(clientA) == VC_OK); + CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000)); + EventStore evB; evB.label = "B"; vc_callbacks cbB{on_event, nullptr, &evB}; @@ -207,6 +214,9 @@ static void test_live_channel_user_list() { CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000)); CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000)); + CHECK(vc_join_voice(clientB) == VC_OK); + CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000)); + uint32_t a_uid = 0, b_uid = 0; { std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; } { std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; } diff --git a/tests/test_dred_toggle.cpp b/tests/test_dred_toggle.cpp index 5e07532..f7843e1 100644 --- a/tests/test_dred_toggle.cpp +++ b/tests/test_dred_toggle.cpp @@ -35,6 +35,7 @@ struct EventStore { bool channel_list_received{false}; bool generic_result_received{false}; bool generic_ok{false}; + bool voice_subscribed{false}; std::vector> streams_started; // (user_id, stream_id) vc_client* client{nullptr}; const char* label{nullptr}; @@ -54,6 +55,9 @@ static void on_event(void* user, const vc_event* ev) { case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break; + case VC_EVENT_VOICE_STATE: + s->voice_subscribed = (ev->u32a == 1); + break; case VC_EVENT_GENERIC_RESULT: s->generic_result_received = true; s->generic_ok = (ev->result == VC_OK); @@ -199,6 +203,9 @@ int main() { CHECK(wait_for(evGuest, [](EventStore& s){ return s.auth_ok; }, 8000)); CHECK(wait_for(evGuest, [](EventStore& s){ return s.channel_list_received; }, 3000)); + CHECK(vc_join_voice(guest) == VC_OK); + CHECK(wait_for(evGuest, [](EventStore& s){ return s.voice_subscribed; }, 5000)); + // Move the guest into the DRED channel. uint32_t guest_uid = 0; { std::lock_guard lk(evGuest.mu); guest_uid = evGuest.self_user_id; } diff --git a/tests/test_external_pcm.cpp b/tests/test_external_pcm.cpp index 6e48080..710fe10 100644 --- a/tests/test_external_pcm.cpp +++ b/tests/test_external_pcm.cpp @@ -47,6 +47,7 @@ struct EventStore { uint32_t self_user_id{0}; bool channel_list_received{false}; bool join_ok{false}; + bool voice_subscribed{false}; std::vector> streams_started; // (user_id, stream_id) vc_client* client{nullptr}; const char* label{nullptr}; @@ -66,6 +67,9 @@ static void on_event(void* user, const vc_event* ev) { case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break; + case VC_EVENT_VOICE_STATE: + s->voice_subscribed = (ev->u32a == 1); + break; case VC_EVENT_JOIN_RESULT: s->join_ok = (ev->result == VC_OK); break; @@ -96,6 +100,8 @@ static bool connect_guest(vc_client*& client, const char* name, const char* labe if (vc_authenticate_guest(client, name) != VC_OK) return false; if (!wait_for(ev, [](EventStore& s) { return s.auth_ok; }, 8000)) return false; if (!wait_for(ev, [](EventStore& s) { return s.channel_list_received; }, 3000)) return false; + if (vc_join_voice(client) != VC_OK) return false; + if (!wait_for(ev, [](EventStore& s) { return s.voice_subscribed; }, 5000)) return false; return true; } diff --git a/tests/test_frame_ms_reframe.cpp b/tests/test_frame_ms_reframe.cpp index e6ec71f..b052a0a 100644 --- a/tests/test_frame_ms_reframe.cpp +++ b/tests/test_frame_ms_reframe.cpp @@ -51,6 +51,7 @@ struct EventStore { bool channel_list_received{false}; bool generic_result_received{false}; bool generic_ok{false}; + bool voice_subscribed{false}; std::vector> streams_started; // (user_id, stream_id) vc_client* client{nullptr}; const char* label{nullptr}; @@ -66,6 +67,7 @@ static void on_event(void* user, const vc_event* ev) { s->self_user_id = ev->user_id; break; case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break; + case VC_EVENT_VOICE_STATE: s->voice_subscribed = (ev->u32a == 1); break; case VC_EVENT_GENERIC_RESULT: s->generic_result_received = true; s->generic_ok = (ev->result == VC_OK); @@ -95,6 +97,8 @@ static bool connect_guest(vc_client*& client, const char* name, const char* labe if (vc_authenticate_guest(client, name) != VC_OK) return false; if (!wait_for(ev, [](EventStore& s) { return s.auth_ok; }, 8000)) return false; if (!wait_for(ev, [](EventStore& s) { return s.channel_list_received; }, 3000)) return false; + if (vc_join_voice(client) != VC_OK) return false; + if (!wait_for(ev, [](EventStore& s) { return s.voice_subscribed; }, 5000)) return false; return true; } diff --git a/tests/test_m2_voice.cpp b/tests/test_m2_voice.cpp index a2d1e31..6887d4d 100644 --- a/tests/test_m2_voice.cpp +++ b/tests/test_m2_voice.cpp @@ -291,6 +291,23 @@ struct TestClient { return true; } + bool do_subscribe_voice() { + { + v1::Envelope env; + env.set_request_id(3); + env.mutable_subscribe_voice(); + if (!tcp_send_envelope(*tls, env)) return false; + } + for (int i = 0; i < 20; ++i) { + v1::Envelope env; + if (!tcp_recv_envelope(*tls, codec, env)) return false; + if (env.has_voice_subscription_result()) { + return env.voice_subscription_result().subscribed(); + } + } + return false; + } + bool do_stream_announce() { // Send StreamAnnounce. { @@ -396,6 +413,7 @@ int main() { CHECK(A.do_hello_and_auth("SenderBob")); CHECK(A.server_udp_port == udp_port.load()); CHECK(A.do_udp_binding()); + CHECK(A.do_subscribe_voice()); CHECK(A.do_stream_announce()); CHECK(A.stream_ok); std::printf("m2_voice: A ssrc=%u local_udp=%u\n", A.assigned_ssrc, A.udp_local_port); @@ -408,6 +426,7 @@ int main() { CHECK(B.do_hello_and_auth("ReceiverAlice")); CHECK(B.server_udp_port == udp_port.load()); CHECK(B.do_udp_binding()); + CHECK(B.do_subscribe_voice()); // B doesn't need to announce a stream to receive relayed frames if (g_failures > 0) { diff --git a/tests/test_m3_multistream.cpp b/tests/test_m3_multistream.cpp index 70205af..b7f0804 100644 --- a/tests/test_m3_multistream.cpp +++ b/tests/test_m3_multistream.cpp @@ -59,6 +59,7 @@ struct EventStore { bool channel_list_received{false}; std::vector stream_events; std::vector talk_events; + bool voice_subscribed{false}; bool disconnected{false}; const char* label{nullptr}; @@ -85,6 +86,9 @@ static void on_event(void* user, const vc_event* ev) { case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break; + case VC_EVENT_VOICE_STATE: + s->voice_subscribed = (ev->u32a == 1); + break; case VC_EVENT_STREAM_STARTED: s->stream_events.push_back({true, ev->user_id, ev->stream_id}); break; @@ -191,6 +195,9 @@ int main() { CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000)); CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000)); + CHECK(vc_join_voice(clientA) == VC_OK); + CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000)); + // ── Client B: guest "M3-B" ──────────────────────────────────────────────── EventStore evB; evB.label = "clientB"; @@ -205,6 +212,9 @@ int main() { CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000)); CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000)); + CHECK(vc_join_voice(clientB) == VC_OK); + CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000)); + uint32_t a_uid = 0; { std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; } diff --git a/tests/test_vad_ptt_devices.cpp b/tests/test_vad_ptt_devices.cpp index b351e54..5a90d40 100644 --- a/tests/test_vad_ptt_devices.cpp +++ b/tests/test_vad_ptt_devices.cpp @@ -59,6 +59,7 @@ struct EventStore { bool channel_list_received{false}; bool saw_stream_started{false}; std::vector talk_events; + bool voice_subscribed{false}; bool disconnected{false}; const char* label{nullptr}; @@ -83,6 +84,9 @@ static void on_event(void* user, const vc_event* ev) { case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break; + case VC_EVENT_VOICE_STATE: + s->voice_subscribed = (ev->u32a == 1); + break; case VC_EVENT_STREAM_STARTED: s->saw_stream_started = true; break; @@ -490,6 +494,9 @@ static void test_vad_and_ptt_gate() { CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000)); CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000)); + CHECK(vc_join_voice(clientA) == VC_OK); + CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000)); + EventStore evB; evB.label = "B"; vc_callbacks cbB{on_event, nullptr, &evB}; @@ -502,6 +509,9 @@ static void test_vad_and_ptt_gate() { CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000)); CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000)); + CHECK(vc_join_voice(clientB) == VC_OK); + CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000)); + uint32_t a_uid = 0; { std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; } diff --git a/tests/test_voice_client_abi.cpp b/tests/test_voice_client_abi.cpp index 98c7e6e..a823182 100644 --- a/tests/test_voice_client_abi.cpp +++ b/tests/test_voice_client_abi.cpp @@ -40,6 +40,7 @@ struct EventStore { uint32_t self_user_id{0}; bool channel_list_received{false}; std::vector stream_events; + bool voice_subscribed{false}; const char* label{nullptr}; bool disconnected{false}; @@ -66,6 +67,9 @@ static void on_event(void* user, const vc_event* ev) { case VC_EVENT_CHANNEL_LIST: s->channel_list_received = true; break; + case VC_EVENT_VOICE_STATE: + s->voice_subscribed = (ev->u32a == 1); + break; case VC_EVENT_STREAM_STARTED: s->stream_events.push_back({true, ev->user_id, ev->stream_id}); break; @@ -159,6 +163,9 @@ int main() { CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000)); CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000)); + CHECK(vc_join_voice(clientA) == VC_OK); + CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000)); + // ── Client B: guest "VoiceB" ─────────────────────────────────────────────── EventStore evB; evB.label = "clientB"; @@ -173,6 +180,9 @@ int main() { CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000)); CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000)); + CHECK(vc_join_voice(clientB) == VC_OK); + CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000)); + uint32_t a_uid = 0; { std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }