2026-06-19 02:10:25 +02:00
|
|
|
import Foundation
|
|
|
|
|
import VoiceCatCore
|
|
|
|
|
|
|
|
|
|
struct PendingIdentity: Identifiable {
|
|
|
|
|
let id = UUID()
|
|
|
|
|
let displayText: String
|
|
|
|
|
let tofuStatus: VoiceCatTofuStatus
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Observable
|
|
|
|
|
@MainActor
|
|
|
|
|
final class AppState {
|
|
|
|
|
var servers: [SavedServer] = ServerListStore.shared.load()
|
|
|
|
|
var session: SessionState?
|
|
|
|
|
|
|
|
|
|
// Connect-flow state
|
|
|
|
|
var isConnecting = false
|
|
|
|
|
var connectStatus = ""
|
|
|
|
|
var showAddServer = false
|
|
|
|
|
var editingServer: SavedServer?
|
|
|
|
|
var showPasswordPrompt = false
|
|
|
|
|
var pendingIdentity: PendingIdentity?
|
|
|
|
|
|
|
|
|
|
private var connectingClient: VoiceCatClient?
|
|
|
|
|
private(set) var connectingServer: SavedServer?
|
|
|
|
|
private var identityHandled = false
|
|
|
|
|
|
|
|
|
|
// MARK: - Server list management
|
|
|
|
|
|
|
|
|
|
func addServer(_ server: SavedServer, password: String?) {
|
|
|
|
|
if let pw = password, !pw.isEmpty {
|
|
|
|
|
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
|
|
|
|
}
|
|
|
|
|
servers.append(server)
|
|
|
|
|
ServerListStore.shared.save(servers)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func updateServer(_ server: SavedServer, password: String?) {
|
|
|
|
|
if let pw = password, !pw.isEmpty {
|
|
|
|
|
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
|
|
|
|
}
|
|
|
|
|
if let idx = servers.firstIndex(where: { $0.id == server.id }) {
|
|
|
|
|
servers[idx] = server
|
|
|
|
|
}
|
|
|
|
|
ServerListStore.shared.save(servers)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func removeServer(_ server: SavedServer) {
|
|
|
|
|
ServerListStore.shared.deletePassword(tag: server.keychainTag)
|
|
|
|
|
servers.removeAll(where: { $0.id == server.id })
|
|
|
|
|
ServerListStore.shared.save(servers)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Connect flow
|
|
|
|
|
|
|
|
|
|
func connectTo(_ server: SavedServer) {
|
|
|
|
|
guard !isConnecting else { return }
|
|
|
|
|
isConnecting = true
|
|
|
|
|
connectStatus = "Connecting…"
|
|
|
|
|
connectingServer = server
|
|
|
|
|
identityHandled = false
|
|
|
|
|
|
|
|
|
|
let config = VoiceCatConfig(
|
|
|
|
|
clientName: "VoiceCat-iOS",
|
|
|
|
|
clientVersion: "0.0.1",
|
|
|
|
|
logLevel: .info,
|
|
|
|
|
tofuStorePath: ServerListStore.shared.tofuStorePath)
|
|
|
|
|
let client = VoiceCatClient(config: config)
|
|
|
|
|
connectingClient = client
|
|
|
|
|
|
|
|
|
|
client.onEvent = { [weak self] ev in
|
|
|
|
|
Task { @MainActor [weak self] in self?.handleConnectEvent(ev, server: server) }
|
|
|
|
|
}
|
2026-06-23 17:47:49 +02:00
|
|
|
// Put the core into external-playback mode BEFORE connect, so the flag is set on the
|
|
|
|
|
// io thread before any message is processed. The server sends AuthResult immediately
|
|
|
|
|
// followed by ServerStateSnapshot; handle_server_state runs ensure_audio_running() on
|
|
|
|
|
// the io thread, and if external_playback_ were still false at that point the core
|
|
|
|
|
// would open a hardware miniaudio playback+capture device (see the matching fix in
|
|
|
|
|
// vc_client::ensure_audio_running). Setting it here — before connect — guarantees the
|
|
|
|
|
// unified external path is in effect from the first frame. setExternalPlayback only
|
|
|
|
|
// flips an atomic + forwards to the engine's setter; both are safe pre-connect.
|
|
|
|
|
client.setExternalPlayback(true)
|
2026-06-19 02:10:25 +02:00
|
|
|
client.connect(host: server.host, port: server.port)
|
|
|
|
|
|
|
|
|
|
// Auth is queued immediately — the core serialises it behind TLS + TOFU.
|
|
|
|
|
switch server.authMode {
|
|
|
|
|
case .guest:
|
feat(clients): expose all channel codec params + guest nickname everywhere
Channel create/edit UIs only surfaced a subset of the core's vc_audio_config,
and DRED was exposed nowhere. While adding it, found a latent ABI mismatch:
both Swift AudioConfig and the C# VcAudioConfigNative blittable struct were one
int short of the native vc_audio_config (missing the trailing `dred`), so native
read past the managed struct in vc_create_channel/vc_edit_channel.
- core marshaling: thread `dred` through Swift (Models/Marshaling/toNative) and
C# (Structs/Models/Marshaling/VoiceCatClient) -- fixes the ABI gap + enables it
- windows: add the one missing DRED checkbox to ChannelEditDialog
- macos: ChannelEditSheet now exposes application, sample rate, packet loss,
complexity, and DRED (was stereo/bitrate/frame/FEC/DTX only)
- ios: rebuild ChannelEditView into a full create+edit form (all params); add
SessionState.editChannel + an admin Edit swipe action (iOS had no edit UI)
- guest nickname: add a dedicated `nickname` to SavedServer on macOS+iOS
(backward-compatible Codable), shown in Guest mode, wired into the guest auth
path -- guests could not set a display name on either before (only Windows)
Verified: macOS + iOS (sim, arm64) xcodebuild BUILD SUCCEEDED; core ctest 22/23
(only external_pcm aborts on a pre-existing shutdown mutex race; no C++ changed).
2026-06-21 04:03:50 +02:00
|
|
|
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
|
2026-06-19 02:10:25 +02:00
|
|
|
client.authenticateGuest(nick)
|
|
|
|
|
case .password:
|
|
|
|
|
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
|
|
|
|
|
if let pw = savedPw, !pw.isEmpty {
|
|
|
|
|
client.authenticateUser(server.savedUsername, password: pw)
|
|
|
|
|
} else {
|
|
|
|
|
showPasswordPrompt = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func disconnect() {
|
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
|
|
|
session?.leaveVoice()
|
2026-06-19 02:10:25 +02:00
|
|
|
session?.client.disconnect()
|
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.stop()
|
2026-06-19 13:46:20 +02:00
|
|
|
AudioSessionManager.shared.deactivateSession()
|
2026-06-19 02:10:25 +02:00
|
|
|
session = nil
|
|
|
|
|
connectingClient?.disconnect()
|
|
|
|
|
connectingClient = nil
|
|
|
|
|
connectingServer = nil
|
|
|
|
|
isConnecting = false
|
|
|
|
|
connectStatus = ""
|
|
|
|
|
showPasswordPrompt = false
|
|
|
|
|
pendingIdentity = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Auth actions (called from prompt sheets)
|
|
|
|
|
|
|
|
|
|
func authenticateUser(username: String, password: String) {
|
|
|
|
|
connectingClient?.authenticateUser(username, password: password)
|
|
|
|
|
showPasswordPrompt = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func confirmServerIdentity(accept: Bool) {
|
|
|
|
|
connectingClient?.confirmServerIdentity(accept: accept)
|
|
|
|
|
pendingIdentity = nil
|
|
|
|
|
if !accept { cancelConnect() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func cancelConnect() {
|
|
|
|
|
connectingClient?.disconnect()
|
|
|
|
|
connectingClient = nil
|
|
|
|
|
connectingServer = nil
|
|
|
|
|
isConnecting = false
|
|
|
|
|
connectStatus = ""
|
|
|
|
|
showPasswordPrompt = false
|
|
|
|
|
pendingIdentity = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Connect event handler
|
|
|
|
|
|
|
|
|
|
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer) {
|
|
|
|
|
switch ev.type {
|
|
|
|
|
case .connectionState:
|
|
|
|
|
switch ev.connectionState {
|
|
|
|
|
case .connecting: connectStatus = "Connecting…"
|
|
|
|
|
case .tlsHandshake: connectStatus = "TLS handshake…"
|
|
|
|
|
case .authenticating: connectStatus = "Authenticating…"
|
|
|
|
|
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
|
|
|
|
case .connected: connectStatus = "Connected"
|
|
|
|
|
default: break
|
|
|
|
|
}
|
|
|
|
|
case .serverIdentity:
|
|
|
|
|
guard !identityHandled else { break }
|
|
|
|
|
let tofuStatus = ev.tofuStatus ?? .firstConnect
|
|
|
|
|
if tofuStatus == .matched {
|
|
|
|
|
connectingClient?.confirmServerIdentity(accept: true)
|
|
|
|
|
} else {
|
|
|
|
|
identityHandled = true
|
|
|
|
|
let displayText = connectingClient?.getServerIdentityDisplay() ?? ""
|
|
|
|
|
pendingIdentity = PendingIdentity(displayText: displayText, tofuStatus: tofuStatus)
|
|
|
|
|
}
|
|
|
|
|
case .authResult:
|
|
|
|
|
if ev.result == .ok {
|
|
|
|
|
guard let client = connectingClient else { break }
|
|
|
|
|
let perms = client.getPermissions()
|
|
|
|
|
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
|
|
|
|
|
connectingClient = nil
|
|
|
|
|
isConnecting = false
|
|
|
|
|
connectStatus = ""
|
|
|
|
|
showPasswordPrompt = false
|
|
|
|
|
self.session = newSession
|
feat(clients): event sound effects + optional text-to-speech
Add audible cues and optional spoken announcements for session events
(join/leave, channel + PM sent/recv, login, logout/connection-lost,
mic on/off, voice-activity, PTT) across all three clients, driven off
the shared C ABI vc_event stream so the mapping stays consistent.
TTS is off by default; when enabled it announces events and reads
message/PM bodies aloud. Master toggles + a sound-volume slider; the
per-utterance voice-activity and PTT cues default off. WAVs ship from
assets/sounds/.
Windows (built + verified): new VoiceCat.App/Notifications/ layer
(FeedbackSettings -> %AppData%\VoiceCat\feedback.json, SoundPlayerPool
via System.Media.SoundPlayer, SpeechAnnouncer via Prismatoid 0.3.0,
EventFeedback dispatcher); MainForm hooks; NotificationSettingsForm
under Settings > Notifications; csproj adds the Prismatoid PackageRef
and copies the WAVs into sounds\.
macOS + iOS (written, not yet built -- needs a Mac): shared
VoiceCatCore/Feedback/ (SoundEvent, EventFeedback = AVAudioPlayer pool
+ native AVSpeechSynthesizer, FeedbackSettings over UserDefaults); WAVs
bundled via Package.swift resources (.process). Hooks in SessionState/
AppState (iOS) and MainWindowController (macOS); settings UI in
SettingsView (iOS) and SettingsWindowController (macOS).
No core/server code touched; ctest --preset dev unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:20:11 +02:00
|
|
|
EventFeedback.shared.play(.login)
|
|
|
|
|
EventFeedback.shared.speak("Connected")
|
2026-06-23 17:47:49 +02:00
|
|
|
// External-playback mode was enabled before connect() so the core never opens a
|
|
|
|
|
// miniaudio device on iOS (the single ordering rule of the unified audio path).
|
|
|
|
|
// Now activate the session and start the engine in listening mode so remote audio
|
|
|
|
|
// plays the moment someone talks, even before we join voice (no "can't hear anyone").
|
2026-06-19 16:58:21 +02:00
|
|
|
do {
|
|
|
|
|
try AudioSessionManager.shared.ensureSessionActive()
|
|
|
|
|
} catch {
|
|
|
|
|
print("Audio session activate on connect failed: \(error)")
|
|
|
|
|
}
|
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.startListening(client: client)
|
2026-06-19 02:10:25 +02:00
|
|
|
} else {
|
|
|
|
|
connectStatus = "Auth failed: \(ev.result.description)"
|
|
|
|
|
showPasswordPrompt = true
|
|
|
|
|
}
|
|
|
|
|
case .disconnected:
|
|
|
|
|
if session == nil { cancelConnect() }
|
2026-06-19 13:46:20 +02:00
|
|
|
else {
|
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.stop()
|
2026-06-19 13:46:20 +02:00
|
|
|
AudioSessionManager.shared.deactivateSession()
|
|
|
|
|
session = nil; isConnecting = false
|
|
|
|
|
}
|
2026-06-19 02:10:25 +02:00
|
|
|
case .error:
|
|
|
|
|
connectStatus = ev.text ?? "Unknown error"
|
|
|
|
|
if session == nil { isConnecting = false }
|
|
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|