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; }