Files
voice-cat/clients/apple/iOS/VoiceCatiOS/SessionState.swift
Talon 6fe7bf0158
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
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).
2026-06-24 14:29:39 +02:00

464 lines
17 KiB
Swift

import Foundation
import AVFoundation
import VoiceCatCore
// MARK: - Helper types
struct ChatMessage: Identifiable {
let id = UUID()
let timestamp: Date
let senderName: String
let text: String
let scope: VoiceCatTextScope
}
struct ActivityEntry: Identifiable {
let id = UUID()
let timestamp: Date
let text: String
}
struct VoiceState {
var micActive = false
var voiceSubscribed = false
var selfMuted = false
var selfDeafened = false
var serverMuted = false
var serverDeafened = false
var inputMode: VoiceCatInputMode = .voiceActivation
var vadThreshold: Float = 0.025
var inputGain: Float = 1.0
var inputNoiseReduction: Bool = false
var level: Float = 0.0
var currentDeviceId: String?
var localStreamId: UInt32 = 0
var screenSharing = false
var screenStreamId: UInt32 = 0
}
// MARK: - SessionState
@Observable
@MainActor
final class SessionState {
let client: VoiceCatClient
let selfUserId: UInt32
var channels: [Channel] = []
var users: [User] = []
var currentChannelId: UInt32 = 0
var messages: [ChatMessage] = []
var activityLog: [ActivityEntry] = []
var voiceState = VoiceState()
var permissions: Permissions
var accounts: [Account] = []
var devices: [Device] = []
/// Host side of iOS screen-audio sharing drains the broadcast extension's App Group ring
/// and feeds the SCREEN_AUDIO stream this session owns. See BroadcastAudioPump.
private let broadcastPump = BroadcastAudioPump()
init(client: VoiceCatClient, selfUserId: UInt32, permissions: Permissions) {
self.client = client
self.selfUserId = selfUserId
self.permissions = permissions
loadAndApplyVoiceSettings()
refreshChannels()
refreshUsers()
syncSelfChannel()
refreshDevices()
client.onEvent = { [weak self] ev in
Task { @MainActor [weak self] in self?.handleEvent(ev) }
}
client.onLevel = { [weak self] _, rms in
Task { @MainActor [weak self] in self?.voiceState.level = rms }
}
broadcastPump.onBroadcastStarted = { [weak self] in self?.startScreenShare() }
broadcastPump.onBroadcastFinished = { [weak self] in self?.stopScreenShare() }
broadcastPump.start()
}
deinit {
broadcastPump.stop()
}
// MARK: - Event dispatch
func handleEvent(_ ev: VoiceCatEvent) {
switch ev.type {
case .channelList:
refreshChannels()
syncSelfChannel()
case .userJoined:
// ev.text = nickname, ev.channelId = the channel they joined (per voicecat.h).
if ev.userId != selfUserId && ev.channelId == currentChannelId {
EventFeedback.shared.play(.channelJoin)
EventFeedback.shared.speak("\(ev.text ?? "Someone") joined")
}
refreshUsers()
syncSelfChannel()
case .userLeft:
// Capture the leaving user's prior nickname/channel before refreshUsers() drops them.
if ev.userId != selfUserId,
let gone = users.first(where: { $0.id == ev.userId }),
gone.channelId == currentChannelId {
EventFeedback.shared.play(.channelLeave)
EventFeedback.shared.speak("\(gone.nickname) left")
}
refreshUsers()
syncSelfChannel()
case .userUpdated:
refreshUsers()
syncSelfChannel()
if let me = users.first(where: { $0.id == selfUserId }) {
applyServerMuteState(muted: me.serverMuted, deafened: me.serverDeafened)
}
case .textMessage:
let sender = users.first(where: { $0.id == ev.userId })?.nickname ?? "Unknown"
let body = ev.text ?? ""
let isSelf = ev.userId == selfUserId
let isPrivate = ev.textScope == .private
messages.append(ChatMessage(
timestamp: Date(timeIntervalSince1970: Double(ev.timestampUnixMs) / 1000),
senderName: sender,
text: body,
scope: ev.textScope))
EventFeedback.shared.play(isPrivate
? (isSelf ? .pmSent : .pmRecv)
: (isSelf ? .channelSent : .channelRecv))
if !isSelf {
EventFeedback.shared.speak(isPrivate
? "Private message from \(sender): \(body)"
: "\(sender): \(body)")
}
case .talkState:
let talking = ev.u32a != 0
if ev.userId == selfUserId {
EventFeedback.shared.play(talking ? .vaStart : .vaStop)
}
let who = users.first(where: { $0.id == ev.userId })?.nickname ?? "user \(ev.userId)"
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
case .streamStarted:
// Our own SCREEN_AUDIO stream is live begin draining the broadcast ring into it,
// in the stream's effective channel mode (downmix to mono if the channel is mono).
if ev.userId == selfUserId && ev.streamId == voiceState.screenStreamId {
let sid = voiceState.screenStreamId
let (r, cfg) = client.getStreamAudioConfig(userId: selfUserId, streamId: sid)
let channels: UInt32 = (r == .ok && cfg?.stereo == true) ? 2 : 1
let c = client
broadcastPump.beginFeeding(streamChannels: channels) { pcm, samples, ch in
c.feedPcm(streamId: sid, pcm: pcm, samplesPerChannel: samples, channels: ch)
}
addActivity("Sharing screen audio (\(channels == 2 ? "stereo" : "mono"))")
break
}
if ev.userId != selfUserId {
do {
try AudioSessionManager.shared.ensureSessionActive()
} catch {
addActivity("Audio session activate failed: \(error)")
}
}
AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))")
addActivity("Stream started (user \(ev.userId))")
case .streamStopped:
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
addActivity("Joined channel")
refreshUsers()
} else {
addActivity("Join failed: \(ev.result.description)")
}
case .error:
addActivity("Error: \(ev.text ?? ev.result.description)")
case .genericResult:
if ev.result != .ok {
addActivity("Operation failed: \(ev.result.description)")
}
case .accountList:
accounts = client.listAccounts()
case .disconnected:
// Audible cue only session teardown is driven elsewhere (AppState / UI).
EventFeedback.shared.play(ev.result == .ok ? .logout : .connectionLost)
EventFeedback.shared.speak(ev.result == .ok ? "Disconnected" : "Connection lost")
default:
break
}
}
private func addActivity(_ text: String) {
activityLog.append(ActivityEntry(timestamp: Date(), text: text))
if activityLog.count > 500 { activityLog.removeFirst() }
}
// MARK: - Self-channel / server-mute sync
/// Sync currentChannelId from the self user's channelId in the user list. Mirrors macOS
/// MainWindowController.swift:461,491,522. The server auto-places every authed user into
/// the Lobby (channel 1) on connect, but without this sync currentChannelId stays 0 and
/// the mic button (gated on currentChannelId == 0) stays permanently dimmed.
private func syncSelfChannel() {
if let me = users.first(where: { $0.id == selfUserId }) {
currentChannelId = me.channelId
}
}
/// Apply server-side mute/deafen state mirrors macOS MainWindowController.swift:693-700.
/// iOS was previously ignoring server mute/deafen entirely.
private func applyServerMuteState(muted: Bool, deafened: Bool) {
if muted && !voiceState.serverMuted { addActivity("You have been server-muted") }
if deafened && !voiceState.serverDeafened { addActivity("You have been server-deafened") }
if !muted && voiceState.serverMuted { addActivity("Server mute cleared") }
if !deafened && voiceState.serverDeafened { addActivity("Server deafen cleared") }
voiceState.serverMuted = muted
voiceState.serverDeafened = deafened
}
// MARK: - Data refresh
func refreshChannels() { channels = client.listChannels() }
func refreshUsers() { users = client.listUsers() }
func refreshDevices() { devices = client.listDevices(.input) }
// MARK: - Voice controls
func joinChannel(_ channelId: UInt32, password: String = "") {
client.joinChannel(channelId, password: password.isEmpty ? nil : password)
}
func leaveChannel() {
client.leaveChannel()
currentChannelId = 0
}
func joinVoice() {
AVAudioApplication.requestRecordPermission { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
if granted {
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")
}
}
}
}
private func doStartMicStream() {
do {
try AudioSessionManager.shared.ensureSessionActive()
} catch {
addActivity("AVAudioSession activate failed: \(error)")
return
}
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic",
externalFeed: true)
let (result, streamId) = client.startStream(desc)
guard result == .ok else {
addActivity("Failed to start mic: \(result.description)")
return
}
voiceState.micActive = true
voiceState.localStreamId = streamId
EventFeedback.shared.play(.voiceOn)
let channels = IOSAudioRouter.shared.captureChannels.channelCount
if channels != 1 {
client.setCaptureChannels(streamId: streamId, channels: channels)
}
IOSAudioEngine.shared.startMic(streamId: streamId, channels: channels)
}
func leaveVoice() {
if voiceState.screenStreamId != 0 { stopScreenShare() }
client.setPushToTalk(false)
IOSAudioEngine.shared.stopMic()
client.leaveVoice()
}
// MARK: - Screen audio share
/// Called when the broadcast extension becomes active. Announces the SCREEN_AUDIO stream;
/// feeding begins on the resulting `.streamStarted` event (see handleEvent). The actual
/// system-audio capture happens in the ReplayKit upload extension (a separate process).
private func startScreenShare() {
guard voiceState.screenStreamId == 0 else { return }
guard currentChannelId != 0 else {
addActivity("Screen audio ignored — join a channel first")
return
}
let (result, streamId) = client.startStream(
StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Screen audio"))
if result == .ok {
voiceState.screenStreamId = streamId
voiceState.screenSharing = true
addActivity("Screen audio share starting…")
} else {
addActivity("Failed to start screen audio: \(result.description)")
}
}
/// Called when the broadcast ends (or on disconnect). Stops feeding and the stream.
private func stopScreenShare() {
broadcastPump.endFeeding()
if voiceState.screenStreamId != 0 {
client.stopStream(voiceState.screenStreamId)
voiceState.screenStreamId = 0
}
if voiceState.screenSharing {
voiceState.screenSharing = false
addActivity("Stopped sharing screen audio")
}
}
func setMute(_ muted: Bool, deafened: Bool) {
client.setSelfMute(micMuted: muted, deafened: deafened)
voiceState.selfMuted = muted
voiceState.selfDeafened = deafened
}
func setInputMode(_ mode: VoiceCatInputMode) {
client.setInputMode(mode)
voiceState.inputMode = mode
UserDefaults.standard.set(Int(mode.rawValue), forKey: DefaultsKey.inputMode)
}
func setVadThreshold(_ threshold: Float) {
client.setVadThreshold(threshold)
voiceState.vadThreshold = threshold
UserDefaults.standard.set(threshold, forKey: DefaultsKey.vadThreshold)
}
func setInputGain(_ gain: Float) {
client.setInputGain(gain)
voiceState.inputGain = gain
UserDefaults.standard.set(gain, forKey: DefaultsKey.inputGain)
}
func setInputNoiseReduction(_ on: Bool) {
client.setInputNoiseReduction(on)
voiceState.inputNoiseReduction = on
UserDefaults.standard.set(on, forKey: DefaultsKey.inputNoiseReduction)
}
// MARK: - Persisted input settings
private enum DefaultsKey {
static let inputMode = "voice.inputMode"
static let vadThreshold = "voice.vadThreshold"
static let inputGain = "voice.inputGain"
static let inputNoiseReduction = "voice.inputNoiseReduction"
}
/// Restore the saved input mode / VAD threshold / mic gain and push them into the core so a
/// relaunch keeps the user's transmission settings instead of resetting to VAD defaults.
private func loadAndApplyVoiceSettings() {
let d = UserDefaults.standard
if d.object(forKey: DefaultsKey.inputMode) != nil {
let raw = UInt32(d.integer(forKey: DefaultsKey.inputMode))
voiceState.inputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation
}
if d.object(forKey: DefaultsKey.vadThreshold) != nil {
voiceState.vadThreshold = d.float(forKey: DefaultsKey.vadThreshold)
}
if d.object(forKey: DefaultsKey.inputGain) != nil {
voiceState.inputGain = d.float(forKey: DefaultsKey.inputGain)
}
if d.object(forKey: DefaultsKey.inputNoiseReduction) != nil {
voiceState.inputNoiseReduction = d.bool(forKey: DefaultsKey.inputNoiseReduction)
}
client.setInputMode(voiceState.inputMode)
client.setVadThreshold(voiceState.vadThreshold)
client.setInputGain(voiceState.inputGain)
client.setInputNoiseReduction(voiceState.inputNoiseReduction)
}
private var pttEngaged = false
func setPushToTalk(_ active: Bool) {
client.setPushToTalk(active)
// Play the PTT cue only on the press transition (the gesture fires repeatedly while held).
if active && !pttEngaged { EventFeedback.shared.play(.ptt) }
pttEngaged = active
}
// MARK: - Text
func sendText(_ text: String, scope: VoiceCatTextScope, targetId: UInt32 = 0) {
client.sendText(scope: scope, targetId: targetId, text: text)
}
// MARK: - Admin
func kickUser(_ userId: UInt32, reason: String) {
client.kickUser(userId, reason: reason.isEmpty ? nil : reason)
}
func banUser(_ userId: UInt32, reason: String, expiresUnixMs: UInt64) {
client.banUser(userId, reason: reason.isEmpty ? nil : reason, expiresUnixMs: expiresUnixMs)
}
func moveUser(_ userId: UInt32, toChannel channelId: UInt32) {
client.moveUser(userId, toChannel: channelId)
}
func setPermissions(_ userId: UInt32, perms: Permissions) {
client.setPermission(userId, perms: perms)
}
func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) {
client.setServerMute(userId, muted: muted, deafened: deafened)
}
func createChannel(_ info: ChannelEdit) {
client.createChannel(info)
}
func editChannel(_ info: ChannelEdit) {
client.editChannel(info)
}
func deleteChannel(_ channelId: UInt32) {
client.deleteChannel(channelId)
}
func fetchAccountList() {
client.requestAccountList()
}
func createAccount(username: String, password: String) {
client.createAccount(username, password: password)
}
func deleteAccount(username: String) {
client.deleteAccount(username)
}
func resetPassword(username: String, newPassword: String) {
client.resetPassword(username, newPassword: newPassword)
}
}