feat: fix voice join/leave, channel edit defaults, channel-update stream restart
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:
@@ -98,7 +98,7 @@ final class AppState {
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
session?.stopMicStream()
|
||||
session?.leaveVoice()
|
||||
session?.client.disconnect()
|
||||
IOSAudioEngine.shared.stop()
|
||||
AudioSessionManager.shared.deactivateSession()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user