Files
voice-cat/clients/apple/iOS/VoiceCatiOS/SessionState.swift

464 lines
17 KiB
Swift
Raw Normal View History

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
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
var voiceSubscribed = false
var selfMuted = false
var selfDeafened = false
feat(ios): audio overhaul, Join/Leave Voice, channel-id sync fix, stereo mic capture Three iOS client problems fixed plus a new core stereo-mic capture ABI: 1. Channel-id sync bug (mic button permanently dimmed): SessionState never synced currentChannelId from the self user's channelId on connect, so the mic button (gated on currentChannelId == 0) stayed dimmed. Added syncSelfChannel() (mirrors macOS MainWindowController.swift:461,491,522); called from init/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult. Added applyServerMuteState() + serverMuted/serverDeafened to VoiceState. 2. Join/Leave Voice button: replaced icon-only mic toggle with explicit text button (parity with macOS). Mute/deafen disable when not in voice. 3. IOSAudioRouter.swift (new): full AVAudioSession routing layer — input port selection, built-in mic orientation/polar patterns, Bluetooth HFP/A2DP/Off modes, Standard/Raw mic processing, stereo capture, AirPlay, UserDefaults persistence. AudioSessionManager delegates to it. 4. Core stereo-mic capture (append-only ABI): vc_set_capture_channels() lets the core open the mic device in stereo (2-ch interleaved). LocalStream gains capture_channels; ensure_audio_running reads it; audio_engine.cpp capture_accum_ + on_capture updated to channel-aware accumulation. Test test_stereo_mic_capture (headless, L!=R stereo round-trip). Swift wrapper VoiceCatClient.setCaptureChannels. 5. Settings UI rework: AVAudioSession-derived input/output tree replaces miniaudio device picker. 6. iOS deployment target raised to 18.0 (Package.swift + project.pbxproj). swift-tools-version 6.0 with swiftLanguageModes .v5. Docs: tech-stack.md, architecture.md, voice.md, roadmap.md, building.md updated; stale 'vc_audio_suspend/resume deferred' claims corrected. Verified: ctest --preset dev 21/21 green; swift test 6/6 green; xcodebuild -target VoiceCatiOS -sdk iphonesimulator BUILD SUCCEEDED.
2026-06-19 13:17:52 +02:00
var serverMuted = false
var serverDeafened = false
var inputMode: VoiceCatInputMode = .voiceActivation
var vadThreshold: Float = 0.025
feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver Input mode (VAD/PTT/Always-On), VAD threshold, and the new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD defaults. Each client now persists them and re-applies on connect: - iOS: UserDefaults (SessionState.loadAndApplyVoiceSettings + setter writes) - macOS: UserDefaults via MainWindowController didSet + loadPersistedAudioSettings (settings window also restores the VAD slider from the stored threshold) - Windows: new Models/VoiceSettings.cs (JSON at %AppData%\VoiceCat\voice.json, mirrors FeedbackSettings) loaded/applied in MainForm Add global send-side mic gain API vc_set_input_gain (applied to MIC PCM in on_capture_frame before the VAD gate, clamped to int16) + Swift/C# bindings, and a 0-300% (default 100%) mic-volume slider on all three clients. Fix iOS chat: ChatView called sendText(scope:.channel) with no targetId (0), so channel messages went nowhere; now passes session.currentChannelId. Fix iOS per-user tuning for VoiceOver: the tuning sheet was long-press .contextMenu only (invisible to VoiceOver); UserRow now also exposes the same buttons via .accessibilityActions (no visual change). Verified: core builds clean; ctest 24/27 (3 pre-existing teardown crashes, reproduced with changes stashed); VoiceCatMac + VoiceCatiOS (arm64 sim) build SUCCEEDED; VoiceCat.Interop dotnet build succeeded. Windows App not built (WinForms can't build on macOS) — follows existing patterns.
2026-06-23 03:35:26 +02:00
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
feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver Input mode (VAD/PTT/Always-On), VAD threshold, and the new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD defaults. Each client now persists them and re-applies on connect: - iOS: UserDefaults (SessionState.loadAndApplyVoiceSettings + setter writes) - macOS: UserDefaults via MainWindowController didSet + loadPersistedAudioSettings (settings window also restores the VAD slider from the stored threshold) - Windows: new Models/VoiceSettings.cs (JSON at %AppData%\VoiceCat\voice.json, mirrors FeedbackSettings) loaded/applied in MainForm Add global send-side mic gain API vc_set_input_gain (applied to MIC PCM in on_capture_frame before the VAD gate, clamped to int16) + Swift/C# bindings, and a 0-300% (default 100%) mic-volume slider on all three clients. Fix iOS chat: ChatView called sendText(scope:.channel) with no targetId (0), so channel messages went nowhere; now passes session.currentChannelId. Fix iOS per-user tuning for VoiceOver: the tuning sheet was long-press .contextMenu only (invisible to VoiceOver); UserRow now also exposes the same buttons via .accessibilityActions (no visual change). Verified: core builds clean; ctest 24/27 (3 pre-existing teardown crashes, reproduced with changes stashed); VoiceCatMac + VoiceCatiOS (arm64 sim) build SUCCEEDED; VoiceCat.Interop dotnet build succeeded. Windows App not built (WinForms can't build on macOS) — follows existing patterns.
2026-06-23 03:35:26 +02:00
loadAndApplyVoiceSettings()
refreshChannels()
refreshUsers()
feat(ios): audio overhaul, Join/Leave Voice, channel-id sync fix, stereo mic capture Three iOS client problems fixed plus a new core stereo-mic capture ABI: 1. Channel-id sync bug (mic button permanently dimmed): SessionState never synced currentChannelId from the self user's channelId on connect, so the mic button (gated on currentChannelId == 0) stayed dimmed. Added syncSelfChannel() (mirrors macOS MainWindowController.swift:461,491,522); called from init/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult. Added applyServerMuteState() + serverMuted/serverDeafened to VoiceState. 2. Join/Leave Voice button: replaced icon-only mic toggle with explicit text button (parity with macOS). Mute/deafen disable when not in voice. 3. IOSAudioRouter.swift (new): full AVAudioSession routing layer — input port selection, built-in mic orientation/polar patterns, Bluetooth HFP/A2DP/Off modes, Standard/Raw mic processing, stereo capture, AirPlay, UserDefaults persistence. AudioSessionManager delegates to it. 4. Core stereo-mic capture (append-only ABI): vc_set_capture_channels() lets the core open the mic device in stereo (2-ch interleaved). LocalStream gains capture_channels; ensure_audio_running reads it; audio_engine.cpp capture_accum_ + on_capture updated to channel-aware accumulation. Test test_stereo_mic_capture (headless, L!=R stereo round-trip). Swift wrapper VoiceCatClient.setCaptureChannels. 5. Settings UI rework: AVAudioSession-derived input/output tree replaces miniaudio device picker. 6. iOS deployment target raised to 18.0 (Package.swift + project.pbxproj). swift-tools-version 6.0 with swiftLanguageModes .v5. Docs: tech-stack.md, architecture.md, voice.md, roadmap.md, building.md updated; stale 'vc_audio_suspend/resume deferred' claims corrected. Verified: ctest --preset dev 21/21 green; swift test 6/6 green; xcodebuild -target VoiceCatiOS -sdk iphonesimulator BUILD SUCCEEDED.
2026-06-19 13:17:52 +02:00
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()
feat(ios): audio overhaul, Join/Leave Voice, channel-id sync fix, stereo mic capture Three iOS client problems fixed plus a new core stereo-mic capture ABI: 1. Channel-id sync bug (mic button permanently dimmed): SessionState never synced currentChannelId from the self user's channelId on connect, so the mic button (gated on currentChannelId == 0) stayed dimmed. Added syncSelfChannel() (mirrors macOS MainWindowController.swift:461,491,522); called from init/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult. Added applyServerMuteState() + serverMuted/serverDeafened to VoiceState. 2. Join/Leave Voice button: replaced icon-only mic toggle with explicit text button (parity with macOS). Mute/deafen disable when not in voice. 3. IOSAudioRouter.swift (new): full AVAudioSession routing layer — input port selection, built-in mic orientation/polar patterns, Bluetooth HFP/A2DP/Off modes, Standard/Raw mic processing, stereo capture, AirPlay, UserDefaults persistence. AudioSessionManager delegates to it. 4. Core stereo-mic capture (append-only ABI): vc_set_capture_channels() lets the core open the mic device in stereo (2-ch interleaved). LocalStream gains capture_channels; ensure_audio_running reads it; audio_engine.cpp capture_accum_ + on_capture updated to channel-aware accumulation. Test test_stereo_mic_capture (headless, L!=R stereo round-trip). Swift wrapper VoiceCatClient.setCaptureChannels. 5. Settings UI rework: AVAudioSession-derived input/output tree replaces miniaudio device picker. 6. iOS deployment target raised to 18.0 (Package.swift + project.pbxproj). swift-tools-version 6.0 with swiftLanguageModes .v5. Docs: tech-stack.md, architecture.md, voice.md, roadmap.md, building.md updated; stale 'vc_audio_suspend/resume deferred' claims corrected. Verified: ctest --preset dev 21/21 green; swift test 6/6 green; xcodebuild -target VoiceCatiOS -sdk iphonesimulator BUILD SUCCEEDED.
2026-06-19 13:17:52 +02:00
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()
feat(ios): audio overhaul, Join/Leave Voice, channel-id sync fix, stereo mic capture Three iOS client problems fixed plus a new core stereo-mic capture ABI: 1. Channel-id sync bug (mic button permanently dimmed): SessionState never synced currentChannelId from the self user's channelId on connect, so the mic button (gated on currentChannelId == 0) stayed dimmed. Added syncSelfChannel() (mirrors macOS MainWindowController.swift:461,491,522); called from init/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult. Added applyServerMuteState() + serverMuted/serverDeafened to VoiceState. 2. Join/Leave Voice button: replaced icon-only mic toggle with explicit text button (parity with macOS). Mute/deafen disable when not in voice. 3. IOSAudioRouter.swift (new): full AVAudioSession routing layer — input port selection, built-in mic orientation/polar patterns, Bluetooth HFP/A2DP/Off modes, Standard/Raw mic processing, stereo capture, AirPlay, UserDefaults persistence. AudioSessionManager delegates to it. 4. Core stereo-mic capture (append-only ABI): vc_set_capture_channels() lets the core open the mic device in stereo (2-ch interleaved). LocalStream gains capture_channels; ensure_audio_running reads it; audio_engine.cpp capture_accum_ + on_capture updated to channel-aware accumulation. Test test_stereo_mic_capture (headless, L!=R stereo round-trip). Swift wrapper VoiceCatClient.setCaptureChannels. 5. Settings UI rework: AVAudioSession-derived input/output tree replaces miniaudio device picker. 6. iOS deployment target raised to 18.0 (Package.swift + project.pbxproj). swift-tools-version 6.0 with swiftLanguageModes .v5. Docs: tech-stack.md, architecture.md, voice.md, roadmap.md, building.md updated; stale 'vc_audio_suspend/resume deferred' claims corrected. Verified: ctest --preset dev 21/21 green; swift test 6/6 green; xcodebuild -target VoiceCatiOS -sdk iphonesimulator BUILD SUCCEEDED.
2026-06-19 13:17:52 +02:00
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
}
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
if ev.userId != selfUserId {
do {
try AudioSessionManager.shared.ensureSessionActive()
} catch {
addActivity("Audio session activate failed: \(error)")
}
}
fix(ios): stop miniaudio from clobbering AVAudioSession (stereo->A2DP output death) The real root cause of "selecting Stereo Mic kills headphone/A2DP output on Join Voice." Every prior fix worked on the Swift IOSAudioRouter under the false premise that "miniaudio does NOT touch AVAudioSession on iOS." It does: the core opened devices via ma_device_init(nullptr, ...), and with a NULL context miniaudio 0.11.25 runs an iOS "hack" that sets the session category by device type, then ma_context_init__coreaudio calls setCategory()+setActive() on every device open -- capture -> AVAudioSessionCategoryRecord with zero options. That wipes the .playAndRecord category, the mode, and .allowBluetoothA2DP / .mixWithOthers / .allowAirPlay that IOSAudioRouter had just configured, killing headphone/A2DP (and even wired) output. Stereo presets break worst because they rely on the A2DP output route the wipe removes. TeamTalk avoids this by opening RemoteIO/VPIO AudioUnits directly and leaving the session entirely to the app. Fix (core, cross-platform safe): AudioEngine now owns a ma_context built by make_context_config() with coreaudio.sessionCategory = ma_ios_session_category_none and noAudioSessionActivate/Deactivate = MA_TRUE, and routes all ma_device_init calls (playback, capture, loopback) plus enumerate_devices through it. miniaudio no longer touches AVAudioSession; IOSAudioRouter is the sole owner (the session is already activated on connect in AppState before any device opens). Context is lazily inited in start(), reused across restarts, uninited in ~AudioEngine. Adds TEMP AudioSessionManager.logSessionState() diagnostics (after activate, on route change, on .streamStarted) to verify on-device that the category stays PlayAndRecord+allowBluetoothA2DP instead of flipping to Record. Remove once confirmed. Windows: cmake --build --preset dev clean; ctest --preset dev 21/21. iOS build + on-device verification pending on Mac. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:34:07 +02:00
AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))")
addActivity("Stream started (user \(ev.userId))")
case .streamStopped:
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
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() }
}
feat(ios): audio overhaul, Join/Leave Voice, channel-id sync fix, stereo mic capture Three iOS client problems fixed plus a new core stereo-mic capture ABI: 1. Channel-id sync bug (mic button permanently dimmed): SessionState never synced currentChannelId from the self user's channelId on connect, so the mic button (gated on currentChannelId == 0) stayed dimmed. Added syncSelfChannel() (mirrors macOS MainWindowController.swift:461,491,522); called from init/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult. Added applyServerMuteState() + serverMuted/serverDeafened to VoiceState. 2. Join/Leave Voice button: replaced icon-only mic toggle with explicit text button (parity with macOS). Mute/deafen disable when not in voice. 3. IOSAudioRouter.swift (new): full AVAudioSession routing layer — input port selection, built-in mic orientation/polar patterns, Bluetooth HFP/A2DP/Off modes, Standard/Raw mic processing, stereo capture, AirPlay, UserDefaults persistence. AudioSessionManager delegates to it. 4. Core stereo-mic capture (append-only ABI): vc_set_capture_channels() lets the core open the mic device in stereo (2-ch interleaved). LocalStream gains capture_channels; ensure_audio_running reads it; audio_engine.cpp capture_accum_ + on_capture updated to channel-aware accumulation. Test test_stereo_mic_capture (headless, L!=R stereo round-trip). Swift wrapper VoiceCatClient.setCaptureChannels. 5. Settings UI rework: AVAudioSession-derived input/output tree replaces miniaudio device picker. 6. iOS deployment target raised to 18.0 (Package.swift + project.pbxproj). swift-tools-version 6.0 with swiftLanguageModes .v5. Docs: tech-stack.md, architecture.md, voice.md, roadmap.md, building.md updated; stale 'vc_audio_suspend/resume deferred' claims corrected. Verified: ctest --preset dev 21/21 green; swift test 6/6 green; xcodebuild -target VoiceCatiOS -sdk iphonesimulator BUILD SUCCEEDED.
2026-06-19 13:17:52 +02:00
// 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
}
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
func joinVoice() {
AVAudioApplication.requestRecordPermission { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
if granted {
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
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 {
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
try AudioSessionManager.shared.ensureSessionActive()
} catch {
addActivity("AVAudioSession activate failed: \(error)")
return
}
let desc = StreamDescriptor(kind: .mic, deviceId: voiceState.currentDeviceId, label: "Mic",
fix(ios-audio): unify iOS audio onto one always-external AVAudioEngine The iOS audio path was a hybrid: Voice-Chat-class presets ran a native VPIO AVAudioEngine (core external) while Stereo/Studio/A2DP presets ran the core's miniaudio devices. Nearly every "no input / no output / both" bug lived in the seam between the two paths — the lingering miniaudio capture unit fighting VPIO, the audioRestart ordering dance, the route-change "glitching" loop, stereo<->mono stickiness, and "can't hear anyone". Switching presets/routes mid-call routinely dropped a direction. Drive ALL iOS audio through one AVAudioEngine with the core fully external at all times: setExternalPlayback(1) once at connect, every MIC stream external_feed=1, mic via vc_stream_feed_pcm, playback via vc_set_mixed_output_sink (drained by an always-on AVAudioSourceNode so remote audio plays before joining voice). VPIO + AGC toggle per preset. Every preset/route/interruption change funnels through one deterministic Swift-only reconfigure (stop -> apply session config -> rebuild -> start) — no second path to hand off to, so a change can't drop a direction. - IOSVoiceProcessingEngine.swift -> IOSAudioEngine: always-on source-node playback, conditional mic tap, VPIO/AGC; one rebuild() backing startListening/stop/startMic/stopMic/reconfigure/setCaptureChannels. - IOSAudioRouter: 7 presets -> 4 (Voice Chat / Stereo Mic / Mono Mic / Advanced); persisted voiceProcessingEnabled + agcEnabled; setters call IOSAudioEngine.reconfigure() instead of audioRestart/reconcileVoicePath. - AudioSessionManager slimmed; SessionState mic lifecycle collapsed; AppState wires external playback + listening at connect, stop at disconnect; SettingsView shows 4 presets + Advanced VPIO/AGC toggles. No core/ABI/test changes — relies on the already-shipped external API (test_external_pcm, test_external_playback). xcodebuild iOS device Debug BUILD SUCCEEDED. Updates docs/voice.md §8 and PROGRESS.md.
2026-06-23 02:45:53 +02:00
externalFeed: true)
let (result, streamId) = client.startStream(desc)
fix(ios-audio): unify iOS audio onto one always-external AVAudioEngine The iOS audio path was a hybrid: Voice-Chat-class presets ran a native VPIO AVAudioEngine (core external) while Stereo/Studio/A2DP presets ran the core's miniaudio devices. Nearly every "no input / no output / both" bug lived in the seam between the two paths — the lingering miniaudio capture unit fighting VPIO, the audioRestart ordering dance, the route-change "glitching" loop, stereo<->mono stickiness, and "can't hear anyone". Switching presets/routes mid-call routinely dropped a direction. Drive ALL iOS audio through one AVAudioEngine with the core fully external at all times: setExternalPlayback(1) once at connect, every MIC stream external_feed=1, mic via vc_stream_feed_pcm, playback via vc_set_mixed_output_sink (drained by an always-on AVAudioSourceNode so remote audio plays before joining voice). VPIO + AGC toggle per preset. Every preset/route/interruption change funnels through one deterministic Swift-only reconfigure (stop -> apply session config -> rebuild -> start) — no second path to hand off to, so a change can't drop a direction. - IOSVoiceProcessingEngine.swift -> IOSAudioEngine: always-on source-node playback, conditional mic tap, VPIO/AGC; one rebuild() backing startListening/stop/startMic/stopMic/reconfigure/setCaptureChannels. - IOSAudioRouter: 7 presets -> 4 (Voice Chat / Stereo Mic / Mono Mic / Advanced); persisted voiceProcessingEnabled + agcEnabled; setters call IOSAudioEngine.reconfigure() instead of audioRestart/reconcileVoicePath. - AudioSessionManager slimmed; SessionState mic lifecycle collapsed; AppState wires external playback + listening at connect, stop at disconnect; SettingsView shows 4 presets + Advanced VPIO/AGC toggles. No core/ABI/test changes — relies on the already-shipped external API (test_external_pcm, test_external_playback). xcodebuild iOS device Debug BUILD SUCCEEDED. Updates docs/voice.md §8 and PROGRESS.md.
2026-06-23 02:45:53 +02:00
guard result == .ok else {
addActivity("Failed to start mic: \(result.description)")
fix(ios-audio): unify iOS audio onto one always-external AVAudioEngine The iOS audio path was a hybrid: Voice-Chat-class presets ran a native VPIO AVAudioEngine (core external) while Stereo/Studio/A2DP presets ran the core's miniaudio devices. Nearly every "no input / no output / both" bug lived in the seam between the two paths — the lingering miniaudio capture unit fighting VPIO, the audioRestart ordering dance, the route-change "glitching" loop, stereo<->mono stickiness, and "can't hear anyone". Switching presets/routes mid-call routinely dropped a direction. Drive ALL iOS audio through one AVAudioEngine with the core fully external at all times: setExternalPlayback(1) once at connect, every MIC stream external_feed=1, mic via vc_stream_feed_pcm, playback via vc_set_mixed_output_sink (drained by an always-on AVAudioSourceNode so remote audio plays before joining voice). VPIO + AGC toggle per preset. Every preset/route/interruption change funnels through one deterministic Swift-only reconfigure (stop -> apply session config -> rebuild -> start) — no second path to hand off to, so a change can't drop a direction. - IOSVoiceProcessingEngine.swift -> IOSAudioEngine: always-on source-node playback, conditional mic tap, VPIO/AGC; one rebuild() backing startListening/stop/startMic/stopMic/reconfigure/setCaptureChannels. - IOSAudioRouter: 7 presets -> 4 (Voice Chat / Stereo Mic / Mono Mic / Advanced); persisted voiceProcessingEnabled + agcEnabled; setters call IOSAudioEngine.reconfigure() instead of audioRestart/reconcileVoicePath. - AudioSessionManager slimmed; SessionState mic lifecycle collapsed; AppState wires external playback + listening at connect, stop at disconnect; SettingsView shows 4 presets + Advanced VPIO/AGC toggles. No core/ABI/test changes — relies on the already-shipped external API (test_external_pcm, test_external_playback). xcodebuild iOS device Debug BUILD SUCCEEDED. Updates docs/voice.md §8 and PROGRESS.md.
2026-06-23 02:45:53 +02:00
return
}
fix(ios-audio): unify iOS audio onto one always-external AVAudioEngine The iOS audio path was a hybrid: Voice-Chat-class presets ran a native VPIO AVAudioEngine (core external) while Stereo/Studio/A2DP presets ran the core's miniaudio devices. Nearly every "no input / no output / both" bug lived in the seam between the two paths — the lingering miniaudio capture unit fighting VPIO, the audioRestart ordering dance, the route-change "glitching" loop, stereo<->mono stickiness, and "can't hear anyone". Switching presets/routes mid-call routinely dropped a direction. Drive ALL iOS audio through one AVAudioEngine with the core fully external at all times: setExternalPlayback(1) once at connect, every MIC stream external_feed=1, mic via vc_stream_feed_pcm, playback via vc_set_mixed_output_sink (drained by an always-on AVAudioSourceNode so remote audio plays before joining voice). VPIO + AGC toggle per preset. Every preset/route/interruption change funnels through one deterministic Swift-only reconfigure (stop -> apply session config -> rebuild -> start) — no second path to hand off to, so a change can't drop a direction. - IOSVoiceProcessingEngine.swift -> IOSAudioEngine: always-on source-node playback, conditional mic tap, VPIO/AGC; one rebuild() backing startListening/stop/startMic/stopMic/reconfigure/setCaptureChannels. - IOSAudioRouter: 7 presets -> 4 (Voice Chat / Stereo Mic / Mono Mic / Advanced); persisted voiceProcessingEnabled + agcEnabled; setters call IOSAudioEngine.reconfigure() instead of audioRestart/reconcileVoicePath. - AudioSessionManager slimmed; SessionState mic lifecycle collapsed; AppState wires external playback + listening at connect, stop at disconnect; SettingsView shows 4 presets + Advanced VPIO/AGC toggles. No core/ABI/test changes — relies on the already-shipped external API (test_external_pcm, test_external_playback). xcodebuild iOS device Debug BUILD SUCCEEDED. Updates docs/voice.md §8 and PROGRESS.md.
2026-06-23 02:45:53 +02:00
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)
}
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
func leaveVoice() {
if voiceState.screenStreamId != 0 { stopScreenShare() }
client.setPushToTalk(false)
fix(ios-audio): unify iOS audio onto one always-external AVAudioEngine The iOS audio path was a hybrid: Voice-Chat-class presets ran a native VPIO AVAudioEngine (core external) while Stereo/Studio/A2DP presets ran the core's miniaudio devices. Nearly every "no input / no output / both" bug lived in the seam between the two paths — the lingering miniaudio capture unit fighting VPIO, the audioRestart ordering dance, the route-change "glitching" loop, stereo<->mono stickiness, and "can't hear anyone". Switching presets/routes mid-call routinely dropped a direction. Drive ALL iOS audio through one AVAudioEngine with the core fully external at all times: setExternalPlayback(1) once at connect, every MIC stream external_feed=1, mic via vc_stream_feed_pcm, playback via vc_set_mixed_output_sink (drained by an always-on AVAudioSourceNode so remote audio plays before joining voice). VPIO + AGC toggle per preset. Every preset/route/interruption change funnels through one deterministic Swift-only reconfigure (stop -> apply session config -> rebuild -> start) — no second path to hand off to, so a change can't drop a direction. - IOSVoiceProcessingEngine.swift -> IOSAudioEngine: always-on source-node playback, conditional mic tap, VPIO/AGC; one rebuild() backing startListening/stop/startMic/stopMic/reconfigure/setCaptureChannels. - IOSAudioRouter: 7 presets -> 4 (Voice Chat / Stereo Mic / Mono Mic / Advanced); persisted voiceProcessingEnabled + agcEnabled; setters call IOSAudioEngine.reconfigure() instead of audioRestart/reconcileVoicePath. - AudioSessionManager slimmed; SessionState mic lifecycle collapsed; AppState wires external playback + listening at connect, stop at disconnect; SettingsView shows 4 presets + Advanced VPIO/AGC toggles. No core/ABI/test changes — relies on the already-shipped external API (test_external_pcm, test_external_playback). xcodebuild iOS device Debug BUILD SUCCEEDED. Updates docs/voice.md §8 and PROGRESS.md.
2026-06-23 02:45:53 +02:00
IOSAudioEngine.shared.stopMic()
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
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
feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver Input mode (VAD/PTT/Always-On), VAD threshold, and the new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD defaults. Each client now persists them and re-applies on connect: - iOS: UserDefaults (SessionState.loadAndApplyVoiceSettings + setter writes) - macOS: UserDefaults via MainWindowController didSet + loadPersistedAudioSettings (settings window also restores the VAD slider from the stored threshold) - Windows: new Models/VoiceSettings.cs (JSON at %AppData%\VoiceCat\voice.json, mirrors FeedbackSettings) loaded/applied in MainForm Add global send-side mic gain API vc_set_input_gain (applied to MIC PCM in on_capture_frame before the VAD gate, clamped to int16) + Swift/C# bindings, and a 0-300% (default 100%) mic-volume slider on all three clients. Fix iOS chat: ChatView called sendText(scope:.channel) with no targetId (0), so channel messages went nowhere; now passes session.currentChannelId. Fix iOS per-user tuning for VoiceOver: the tuning sheet was long-press .contextMenu only (invisible to VoiceOver); UserRow now also exposes the same buttons via .accessibilityActions (no visual change). Verified: core builds clean; ctest 24/27 (3 pre-existing teardown crashes, reproduced with changes stashed); VoiceCatMac + VoiceCatiOS (arm64 sim) build SUCCEEDED; VoiceCat.Interop dotnet build succeeded. Windows App not built (WinForms can't build on macOS) — follows existing patterns.
2026-06-23 03:35:26 +02:00
UserDefaults.standard.set(Int(mode.rawValue), forKey: DefaultsKey.inputMode)
}
func setVadThreshold(_ threshold: Float) {
client.setVadThreshold(threshold)
voiceState.vadThreshold = threshold
feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver Input mode (VAD/PTT/Always-On), VAD threshold, and the new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD defaults. Each client now persists them and re-applies on connect: - iOS: UserDefaults (SessionState.loadAndApplyVoiceSettings + setter writes) - macOS: UserDefaults via MainWindowController didSet + loadPersistedAudioSettings (settings window also restores the VAD slider from the stored threshold) - Windows: new Models/VoiceSettings.cs (JSON at %AppData%\VoiceCat\voice.json, mirrors FeedbackSettings) loaded/applied in MainForm Add global send-side mic gain API vc_set_input_gain (applied to MIC PCM in on_capture_frame before the VAD gate, clamped to int16) + Swift/C# bindings, and a 0-300% (default 100%) mic-volume slider on all three clients. Fix iOS chat: ChatView called sendText(scope:.channel) with no targetId (0), so channel messages went nowhere; now passes session.currentChannelId. Fix iOS per-user tuning for VoiceOver: the tuning sheet was long-press .contextMenu only (invisible to VoiceOver); UserRow now also exposes the same buttons via .accessibilityActions (no visual change). Verified: core builds clean; ctest 24/27 (3 pre-existing teardown crashes, reproduced with changes stashed); VoiceCatMac + VoiceCatiOS (arm64 sim) build SUCCEEDED; VoiceCat.Interop dotnet build succeeded. Windows App not built (WinForms can't build on macOS) — follows existing patterns.
2026-06-23 03:35:26 +02:00
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)
}
feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver Input mode (VAD/PTT/Always-On), VAD threshold, and the new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD defaults. Each client now persists them and re-applies on connect: - iOS: UserDefaults (SessionState.loadAndApplyVoiceSettings + setter writes) - macOS: UserDefaults via MainWindowController didSet + loadPersistedAudioSettings (settings window also restores the VAD slider from the stored threshold) - Windows: new Models/VoiceSettings.cs (JSON at %AppData%\VoiceCat\voice.json, mirrors FeedbackSettings) loaded/applied in MainForm Add global send-side mic gain API vc_set_input_gain (applied to MIC PCM in on_capture_frame before the VAD gate, clamped to int16) + Swift/C# bindings, and a 0-300% (default 100%) mic-volume slider on all three clients. Fix iOS chat: ChatView called sendText(scope:.channel) with no targetId (0), so channel messages went nowhere; now passes session.currentChannelId. Fix iOS per-user tuning for VoiceOver: the tuning sheet was long-press .contextMenu only (invisible to VoiceOver); UserRow now also exposes the same buttons via .accessibilityActions (no visual change). Verified: core builds clean; ctest 24/27 (3 pre-existing teardown crashes, reproduced with changes stashed); VoiceCatMac + VoiceCatiOS (arm64 sim) build SUCCEEDED; VoiceCat.Interop dotnet build succeeded. Windows App not built (WinForms can't build on macOS) — follows existing patterns.
2026-06-23 03:35:26 +02:00
// 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"
feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver Input mode (VAD/PTT/Always-On), VAD threshold, and the new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD defaults. Each client now persists them and re-applies on connect: - iOS: UserDefaults (SessionState.loadAndApplyVoiceSettings + setter writes) - macOS: UserDefaults via MainWindowController didSet + loadPersistedAudioSettings (settings window also restores the VAD slider from the stored threshold) - Windows: new Models/VoiceSettings.cs (JSON at %AppData%\VoiceCat\voice.json, mirrors FeedbackSettings) loaded/applied in MainForm Add global send-side mic gain API vc_set_input_gain (applied to MIC PCM in on_capture_frame before the VAD gate, clamped to int16) + Swift/C# bindings, and a 0-300% (default 100%) mic-volume slider on all three clients. Fix iOS chat: ChatView called sendText(scope:.channel) with no targetId (0), so channel messages went nowhere; now passes session.currentChannelId. Fix iOS per-user tuning for VoiceOver: the tuning sheet was long-press .contextMenu only (invisible to VoiceOver); UserRow now also exposes the same buttons via .accessibilityActions (no visual change). Verified: core builds clean; ctest 24/27 (3 pre-existing teardown crashes, reproduced with changes stashed); VoiceCatMac + VoiceCatiOS (arm64 sim) build SUCCEEDED; VoiceCat.Interop dotnet build succeeded. Windows App not built (WinForms can't build on macOS) — follows existing patterns.
2026-06-23 03:35:26 +02:00
}
/// 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)
}
feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver Input mode (VAD/PTT/Always-On), VAD threshold, and the new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD defaults. Each client now persists them and re-applies on connect: - iOS: UserDefaults (SessionState.loadAndApplyVoiceSettings + setter writes) - macOS: UserDefaults via MainWindowController didSet + loadPersistedAudioSettings (settings window also restores the VAD slider from the stored threshold) - Windows: new Models/VoiceSettings.cs (JSON at %AppData%\VoiceCat\voice.json, mirrors FeedbackSettings) loaded/applied in MainForm Add global send-side mic gain API vc_set_input_gain (applied to MIC PCM in on_capture_frame before the VAD gate, clamped to int16) + Swift/C# bindings, and a 0-300% (default 100%) mic-volume slider on all three clients. Fix iOS chat: ChatView called sendText(scope:.channel) with no targetId (0), so channel messages went nowhere; now passes session.currentChannelId. Fix iOS per-user tuning for VoiceOver: the tuning sheet was long-press .contextMenu only (invisible to VoiceOver); UserRow now also exposes the same buttons via .accessibilityActions (no visual change). Verified: core builds clean; ctest 24/27 (3 pre-existing teardown crashes, reproduced with changes stashed); VoiceCatMac + VoiceCatiOS (arm64 sim) build SUCCEEDED; VoiceCat.Interop dotnet build succeeded. Windows App not built (WinForms can't build on macOS) — follows existing patterns.
2026-06-23 03:35:26 +02:00
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)
}
}