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) }
|
|
|
|
|
}
|
|
|
|
|
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() {
|
2026-06-19 13:46:20 +02:00
|
|
|
session?.stopMicStream()
|
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")
|
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
|
|
|
// Put the core into external-playback mode ONCE, now, before the session is
|
|
|
|
|
// activated or any remote stream can arrive — so the core never opens a miniaudio
|
|
|
|
|
// device on iOS (the single ordering rule of the unified audio path). Then 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").
|
|
|
|
|
client.setExternalPlayback(true)
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|