feat: fix voice join/leave, channel edit defaults, channel-update stream restart
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

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).
This commit is contained in:
2026-06-24 14:29:39 +02:00
parent 2baefddbe4
commit 6fe7bf0158
40 changed files with 701 additions and 71 deletions

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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.

View File

@@ -98,7 +98,7 @@ final class AppState {
}
func disconnect() {
session?.stopMicStream()
session?.leaveVoice()
session?.client.disconnect()
IOSAudioEngine.shared.stop()
AudioSessionManager.shared.deactivateSession()

View File

@@ -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

View File

@@ -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() {

View File

@@ -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")

View File

@@ -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")

View File

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

View File

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

View File

@@ -92,6 +92,8 @@ public enum VcEventType
GenericResult = 14,
/// <summary>M5: reply to VoiceCatClient.RequestAccountList — call ListAccounts() to read.</summary>
AccountList = 15,
/// <summary>Voice-plane subscription state. u32a = 1 (subscribed) or 0 (unsubscribed).</summary>
VoiceState = 16,
}
/// <summary>

View File

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)]

View File

@@ -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<ChannelInfo> ListChannels()
{
NativeMethods.vc_list_channels(_handle.DangerousGetHandle(), out var native);